Merge branch 'master' into chrischinchilla/remove-auth-helpers

This commit is contained in:
Chris Chinchilla committed 2025-12-16 16:51:37 +01:00
commit b040b838dc
492 files changed
+40444 -11852

No files matched your search

+324
View File
@@ -0,0 +1,324 @@
---
description: E2E testing best practices for Playwright tests in Studio
globs:
- e2e/studio/**/*.ts
- e2e/studio/**/*.spec.ts
alwaysApply: true
---
# E2E Testing Best Practices
## Getting Context
Before writing or modifying tests, use the Playwright MCP to understand:
- Available page elements and their roles/locators
- Current page state and network activity
- Existing test patterns in the codebase
Avoid extensive code reading - let Playwright's inspection tools guide your understanding of the UI.
## Avoiding Race Conditions
### Set up API waiters BEFORE triggering actions
The most common source of flaky tests is race conditions between UI actions and API calls. Always create response waiters before clicking buttons or navigating.
```ts
// ❌ Bad - race condition: response might complete before waiter is set up
await page.getByRole('button', { name: 'Save' }).click()
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
// ✅ Good - waiter is ready before action
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
await page.getByRole('button', { name: 'Save' }).click()
await apiPromise
```
### Use `createApiResponseWaiter` for pre-navigation waits
When you need to wait for a response that happens during navigation:
```ts
// ✅ Good - waiter created before navigation
const loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
```
### Wait for multiple related API calls with Promise.all
When an action triggers multiple API calls, wait for all of them:
```ts
// ✅ Good - wait for all related API calls
const createTablePromise = waitForApiResponseWithTimeout(page, (response) =>
response.url().includes('query?key=table-create')
)
const tablesPromise = waitForApiResponseWithTimeout(page, (response) =>
response.url().includes('tables?include_columns=true')
)
const entitiesPromise = waitForApiResponseWithTimeout(page, (response) =>
response.url().includes('query?key=entity-types-')
)
await page.getByRole('button', { name: 'Save' }).click()
await Promise.all([createTablePromise, tablesPromise, entitiesPromise])
```
## Waiting Strategies
### Prefer Playwright's built-in auto-waiting
Playwright automatically waits for elements to be actionable. Use this instead of manual timeouts:
```ts
// ❌ Bad - arbitrary timeout
await page.waitForTimeout(2000)
await page.getByRole('button', { name: 'Submit' }).click()
// ✅ Good - auto-waits for element to be visible and enabled
await page.getByRole('button', { name: 'Submit' }).click()
```
### Use `expect.poll` for dynamic assertions
When waiting for state to change:
```ts
// ✅ Good - polls until condition is met
await expect
.poll(async () => {
return await page.getByLabel(`View ${tableName}`).count()
})
.toBe(0)
```
### Use `waitForSelector` with state for element lifecycle
```ts
// ✅ Good - wait for panel to close
await page.waitForSelector('[data-testid="side-panel"]', { state: 'detached' })
```
### Avoid `networkidle` - use specific API waits instead
```ts
// ❌ Bad - unreliable and slow
await page.waitForLoadState('networkidle')
// ✅ Good - wait for specific API response
await waitForApiResponse(page, 'pg-meta', ref, 'tables')
```
### Use timeouts sparingly and only for non-API waits
```ts
// ✅ Acceptable - waiting for client-side debounce
await page.getByRole('textbox').fill('search term')
await page.waitForTimeout(300) // Allow debounce to complete
// ✅ Acceptable - waiting for clipboard API
await page.evaluate(() => navigator.clipboard.readText())
await page.waitForTimeout(500)
```
## Test Structure
### Use the custom test utility
Always import from the custom test utility for consistent fixtures:
```ts
import { test } from '../utils/test.js'
```
### Use `withFileOnceSetup` for expensive setup
When setup is expensive (cleanup, seeding), run it once per file:
```ts
test.beforeAll(async ({ browser, ref }) => {
await withFileOnceSetup(import.meta.url, async () => {
const ctx = await browser.newContext()
const page = await ctx.newPage()
// Expensive setup logic (e.g., cleanup old test data)
await deleteTestTables(page, ref)
})
})
test.afterAll(async () => {
await releaseFileOnceCleanup(import.meta.url)
})
```
### Dismiss toasts before interacting with UI
Toasts can overlay buttons and block interactions:
```ts
const dismissToastsIfAny = async (page: Page) => {
const closeButtons = page.getByRole('button', { name: 'Close toast' })
const count = await closeButtons.count()
for (let i = 0; i < count; i++) {
await closeButtons.nth(i).click()
}
}
// ✅ Good - dismiss toasts before clicking
await dismissToastsIfAny(page)
await page.getByRole('button', { name: 'New table' }).click()
```
## Assertions
### Always include descriptive messages
```ts
// ❌ Bad - no context on failure
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
// ✅ Good - clear message on failure
await expect(
page.getByRole('button', { name: 'Save' }),
'Save button should be visible after form is filled'
).toBeVisible()
```
### Use appropriate timeouts for slow operations
```ts
// ✅ Good - explicit timeout for slow operations
await expect(
page.getByText(`Table ${tableName} is good to go!`),
'Success toast should be visible after table creation'
).toBeVisible({ timeout: 50000 })
```
## Locators
### Prefer role-based locators
```ts
// ✅ Good - semantic and resilient
page.getByRole('button', { name: 'Save' })
page.getByRole('textbox', { name: 'Username' })
page.getByRole('menuitem', { name: 'Delete' })
// ❌ Avoid - brittle CSS selectors
page.locator('.btn-primary')
page.locator('#submit-button')
```
### Use test IDs for complex elements
```ts
// ✅ Good - stable identifier for complex elements
page.getByTestId('table-editor-side-panel')
page.getByTestId('action-bar-save-row')
```
### Use `filter` for finding elements in context
```ts
// ✅ Good - find button within specific row
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
await bucketRow.getByRole('button').click()
```
## Helper Functions
### Extract reusable operations into helpers
Create helper functions for common operations:
```ts
// e2e/studio/utils/storage-helpers.ts
export const createBucket = async (
page: Page,
ref: string,
bucketName: string,
isPublic: boolean = false
) => {
await navigateToStorageFiles(page, ref)
// Check if already exists
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
if ((await bucketRow.count()) > 0) return
await dismissToastsIfAny(page)
// Create bucket with proper waits
const apiPromise = waitForApiResponse(page, 'storage', ref, 'bucket', { method: 'POST' })
await page.getByRole('button', { name: 'New bucket' }).click()
await page.getByRole('textbox', { name: 'Bucket name' }).fill(bucketName)
await page.getByRole('button', { name: 'Create' }).click()
await apiPromise
await expect(
page.getByRole('row').filter({ hasText: bucketName }),
`Bucket ${bucketName} should be visible`
).toBeVisible()
}
```
### Use the existing wait utilities
```ts
import {
createApiResponseWaiter,
waitForApiResponse,
waitForGridDataToLoad,
waitForTableToLoad,
} from '../utils/wait-for-response.js'
```
## API Mocking
### Mock APIs for isolated testing
```ts
// ✅ Good - mock API response
await page.route('*/**/logs.all*', async (route) => {
await route.fulfill({ body: JSON.stringify(mockAPILogs) })
})
```
### Use soft waits for optional API calls
```ts
// ✅ Good - don't fail if API doesn't respond
await waitForApiResponse(page, 'pg-meta', ref, 'optional-endpoint', {
soft: true,
fallbackWaitMs: 1000,
})
```
## Cleanup
### Clean up test data in beforeAll/beforeEach
```ts
test.beforeEach(async ({ page, ref }) => {
await deleteAllBuckets(page, ref)
})
```
### Handle existing state gracefully
```ts
// ✅ Good - check before trying to delete
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
if ((await bucketRow.count()) === 0) return
// proceed with deletion
```
### Reset local storage when needed
```ts
import { resetLocalStorage } from '../utils/reset-local-storage.js'
// Clean up after tests that modify local storage
await resetLocalStorage(page, ref)
```
+434
View File
@@ -0,0 +1,434 @@
---
description: React best practices and coding standards for Studio
globs:
- apps/studio/**/*.tsx
- apps/studio/**/*.ts
alwaysApply: true
---
# Studio Best Practices
## Boolean Handling
### Assign complex conditions to descriptive variables
When you have multiple conditions in a single expression, extract them into well-named boolean variables. This improves readability and makes the code self-documenting.
```tsx
// ❌ Bad - complex inline condition
{
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading && (
<Button onClick={onAddColumn}>New column</Button>
)
}
// ✅ Good - extract to descriptive variables
const isTableEntity = isTableLike(selectedTable)
const canShowAddButton = !isSchemaLocked && isTableEntity && canUpdateColumns && !isLoading
{
canShowAddButton && <Button onClick={onAddColumn}>New column</Button>
}
```
### Use consistent naming conventions for booleans
- Use `is` prefix for state/identity: `isLoading`, `isPaused`, `isNewRecord`, `isError`
- Use `has` prefix for possession: `hasPermission`, `hasShownModal`, `hasData`
- Use `can` prefix for capability/permission: `canUpdateColumns`, `canDelete`, `canEdit`
- Use `should` prefix for conditional behavior: `shouldFetch`, `shouldRender`, `shouldValidate`
```tsx
// ✅ Good examples from codebase
const isNewRecord = column === undefined
const isPaused = project?.status === PROJECT_STATUS.INACTIVE
const isMatureProject = dayjs(project?.inserted_at).isBefore(dayjs().subtract(10, 'day'))
const { can: canUpdateColumns } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'columns'
)
```
### Derive boolean state instead of storing it
When a boolean can be computed from existing state, derive it rather than storing it separately.
```tsx
// ❌ Bad - storing derived state
const [isFormValid, setIsFormValid] = useState(false)
useEffect(() => {
setIsFormValid(name.length > 0 && email.includes('@'))
}, [name, email])
// ✅ Good - derive from existing state
const isFormValid = name.length > 0 && email.includes('@')
```
## Component Structure
### Break down large components
Components should ideally be under 200-300 lines. If a component grows larger, consider splitting it.
**Signs a component should be split:**
- Multiple distinct UI sections
- Complex conditional rendering logic
- Multiple useState hooks for unrelated state
- Difficult to understand at a glance
```tsx
// ❌ Bad - monolithic component with everything inline
const UserDashboard = () => {
// 50 lines of hooks and state
// 100 lines of handlers
// 300 lines of JSX with nested conditions
}
// ✅ Good - split into focused sub-components
const UserDashboard = () => {
return (
<div>
<UserHeader />
<UserStats />
<UserActivitySection />
<UserSettingsPanel />
</div>
)
}
```
### Co-locate related components
Place sub-components in the same directory as the parent component. Use an index file for cleaner imports.
```
components/interfaces/Auth/Users/
├── UserPanel.tsx
├── UserOverview.tsx
├── UserLogs.tsx
├── Users.constants.ts
└── index.ts
```
### Extract repeated JSX patterns
If you find yourself copying similar JSX blocks, extract them into a component.
```tsx
// ❌ Bad - repeated pattern
<TabsTrigger_Shadcn_ value="overview" className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none">
Overview
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="logs" className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none">
Logs
</TabsTrigger_Shadcn_>
// ✅ Good - extract to component
const PanelTab = ({ value, children }: { value: string; children: ReactNode }) => (
<TabsTrigger_Shadcn_
value={value}
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none"
>
{children}
</TabsTrigger_Shadcn_>
)
```
## Loading and Error States
### Use consistent loading/error/success pattern
Follow a consistent pattern for handling async states:
```tsx
const { data, error, isLoading, isError, isSuccess } = useQuery()
// Handle loading state first
if (isLoading) {
return <GenericSkeletonLoader />
}
// Handle error state
if (isError) {
return <AlertError error={error} subject="Failed to load data" />
}
// Handle empty state if needed
if (isSuccess && data.length === 0) {
return <EmptyState />
}
// Render success state
return <DataDisplay data={data} />
```
### Use early returns for guard clauses
Prefer early returns over deeply nested conditionals:
```tsx
// ❌ Bad - deeply nested
const Component = () => {
if (data) {
if (!isError) {
if (hasPermission) {
return <ActualContent />
}
}
}
return null
}
// ✅ Good - early returns
const Component = () => {
if (!data) return null
if (isError) return <ErrorDisplay />
if (!hasPermission) return <PermissionDenied />
return <ActualContent />
}
```
## State Management
### Keep state as local as possible
Start with local state and lift up only when needed.
```tsx
// ✅ Good - state lives where it's used
const SearchableList = () => {
const [filterString, setFilterString] = useState('')
const filteredItems = items.filter((item) => item.name.includes(filterString))
return (
<div>
<Input value={filterString} onChange={(e) => setFilterString(e.target.value)} />
<List items={filteredItems} />
</div>
)
}
```
### Group related state with objects or reducers
When you have multiple related pieces of state, consider grouping them:
```tsx
// ❌ Bad - multiple related useState calls
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
// ✅ Good - grouped state for forms (use react-hook-form)
const form = useForm<FormValues>({
defaultValues: { name: '', email: '', phone: '' },
})
```
## Custom Hooks
### Extract complex logic into custom hooks
When logic becomes reusable or complex, extract it:
```tsx
// ✅ Good - extracted to custom hook
export function useAsyncCheckPermissions(action: string, resource: string) {
const { permissions, isLoading, isSuccess } = useGetProjectPermissions()
const can = useMemo(() => {
if (!IS_PLATFORM) return true
if (!isSuccess || !permissions) return false
return doPermissionsCheck(permissions, action, resource)
}, [isSuccess, permissions, action, resource])
return { isLoading, isSuccess, can }
}
// Usage
const { can: canUpdateColumns } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'columns'
)
```
### Return objects from hooks for better extensibility
```tsx
// ❌ Bad - returning array (hard to extend)
const useToggle = () => {
const [value, setValue] = useState(false)
return [value, () => setValue((v) => !v)]
}
// ✅ Good - returning object (easy to extend)
const useToggle = (initial = false) => {
const [value, setValue] = useState(initial)
return {
value,
toggle: () => setValue((v) => !v),
setTrue: () => setValue(true),
setFalse: () => setValue(false),
}
}
```
## Event Handlers
### Name handlers consistently
Use `on` prefix for prop callbacks and `handle` prefix for internal handlers:
```tsx
interface Props {
onClose: () => void // Callback prop
onSave: (data: Data) => void
}
const Component = ({ onClose, onSave }: Props) => {
const handleSubmit = () => {
// Internal handler
// process data
onSave(data)
}
const handleCancel = () => {
// cleanup
onClose()
}
}
```
### Avoid inline arrow functions for expensive operations
```tsx
// ❌ Bad - creates new function every render
<ExpensiveList
items={items}
onItemClick={(item) => handleItemClick(item)}
/>
// ✅ Good - stable reference with useCallback
const handleItemClick = useCallback((item: Item) => {
// handle click
}, [dependencies])
<ExpensiveList items={items} onItemClick={handleItemClick} />
```
## Conditional Rendering
### Use appropriate patterns for different scenarios
```tsx
// Simple show/hide - use &&
{
isVisible && <Component />
}
// Binary choice - use ternary
{
isLoading ? <Spinner /> : <Content />
}
// Multiple conditions - use early returns or extracted component
const StatusDisplay = ({ status }: { status: Status }) => {
if (status === 'loading') return <Spinner />
if (status === 'error') return <ErrorMessage />
if (status === 'empty') return <EmptyState />
return <DataDisplay />
}
```
### Avoid nested ternaries
```tsx
// ❌ Bad - nested ternary
{
isLoading ? <Spinner /> : isError ? <Error /> : <Content />
}
// ✅ Good - separate conditions or early returns
if (isLoading) return <Spinner />
if (isError) return <Error />
return <Content />
```
## Performance
### Use useMemo for expensive computations
```tsx
// ✅ Good - memoize expensive filtering
const filteredItems = useMemo(
() => items.filter((item) => item.name.toLowerCase().includes(searchQuery.toLowerCase())),
[items, searchQuery]
)
```
### Avoid premature optimization
Don't wrap everything in useMemo/useCallback. Only optimize when:
- You have measured a performance problem
- The computation is genuinely expensive
- The value is passed to memoized children
## TypeScript
### Define prop interfaces explicitly
```tsx
interface UserCardProps {
user: User
onEdit: (user: User) => void
onDelete: (userId: string) => void
isEditable?: boolean
}
export const UserCard = ({ user, onEdit, onDelete, isEditable = true }: UserCardProps) => {
// ...
}
```
### Use discriminated unions for complex state
```tsx
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
```
### Avoid type casting, prefer validation with zod
Never use type casting (e.g., `as any`, `as Type`). Instead, validate values at runtime using zod schemas. This ensures type safety and catches runtime errors.
```tsx
// ❌ Bad - type casting bypasses type checking
const user = apiResponse as User
const data = unknownValue as string
// ✅ Good - validate with zod schema
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
const user = userSchema.parse(apiResponse)
const data = z.string().parse(unknownValue)
// ✅ Good - safe parsing with error handling
const result = userSchema.safeParse(apiResponse)
if (result.success) {
const user = result.data
} else {
// handle validation errors
}
```
+173 -26
View File
@@ -125,63 +125,106 @@ export const MyPageComponent = () => (
## Forms
- Build forms with `react-hook-form` + `zod`.
- Use our `_Shadcn_` form primitives from `ui` and prefer `FormItemLayout` with layout="flex-row-reverse" for most controls (see `apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx`).
- Keep imports from `ui` with `_Shadcn_` suffixes.
- Forms should generally be wrapped in a Card unless specified
- If the submit button is outside the form, add a new variable named formId outside the component, and set it as property id on the form element and formId on the button.
Forms in Supabase Studio should follow consistent patterns to ensure a cohesive user experience across settings pages and side panels.
### Example (single field)
### Core Principles
- Build forms with `react-hook-form` + `zod`
- Always use `FormItemLayout` instead of manually composing `FormItem`, `FormLabel`, `FormMessage`, and `FormDescription`
- Always wrap form inputs with `FormControl_Shadcn_` to ensure proper form integration
- Keep imports from `ui` with `_Shadcn_` suffixes
- Handle dirty state: Show cancel buttons and disable save buttons based on `form.formState.isDirty`
- Show loading states on submit buttons using the `loading` prop
- If the submit button is outside the form, add a `formId` variable outside the component, set it as `id` on the form element and `form` prop on the button
### Layout Selection
- **Page layouts**: Use `FormItemLayout` with `layout="flex-row-reverse"` for horizontal alignment. Forms should be wrapped in a `Card` with each form field in its own `CardContent`, and `CardFooter` for actions. The layout automatically handles consistent input widths (50% on md, 40% on xl, min-w-100).
- **Side panels (wide)**: Use `FormItemLayout` with `layout="horizontal"`. Use `SheetSection` to wrap each field group.
- **Side panels (narrow, size="sm" or below)**: Use `FormItemLayout` with `layout="vertical"`
### Page Layout Form Example
```tsx
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { Button, Form_Shadcn_, FormField_Shadcn_, FormControl_Shadcn_, Input_Shadcn_ } from 'ui'
import {
Button,
Card,
CardContent,
CardFooter,
Form_Shadcn_,
FormField_Shadcn_,
FormControl_Shadcn_,
Input_Shadcn_,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
const profileSchema = z.object({
username: z.string().min(2, 'Username must be at least 2 characters'),
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
enableFeature: z.boolean(),
})
const formId = `profile-form`
export function ProfileForm() {
const form = useForm<z.infer<typeof profileSchema>>({
resolver: zodResolver(profileSchema),
defaultValues: { username: '' },
export function SettingsForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { name: '', enableFeature: false },
mode: 'onSubmit',
reValidateMode: 'onBlur',
})
function onSubmit(values: z.infer<typeof profileSchema>) {
// handle values
function onSubmit(values: z.infer<typeof formSchema>) {
// handle mutation with onSuccess/onError toast
}
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent className="space-y-6">
<CardContent>
<FormField_Shadcn_
control={form.control}
name="username"
name="name"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Username"
description="This is your public display name."
label="Name"
description="A descriptive name for this resource"
>
<FormControl_Shadcn_>
<Input_Shadcn_ placeholder="shadcn" autoComplete="off" {...field} />
<Input_Shadcn_ {...field} placeholder="Enter name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="justify-end">
<Button type="primary" htmlType="submit">
<CardContent>
<FormField_Shadcn_
control={form.control}
name="enableFeature"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enable Feature"
description="Toggle this feature on or off"
>
<FormControl_Shadcn_>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</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}>
Submit
</Button>
</CardFooter>
@@ -192,6 +235,106 @@ export function ProfileForm() {
}
```
### Side Panel Form Example
```tsx
import { zodResolver } from '@hookform/resolvers/zod'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import {
Button,
Form_Shadcn_,
FormField_Shadcn_,
FormControl_Shadcn_,
Input_Shadcn_,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
})
const formId = 'sidepanel-form'
export function CreateResourcePanel() {
const [open, setOpen] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { name: '' },
})
function onSubmit(values: z.infer<typeof formSchema>) {
// handle mutation
setOpen(false)
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent size="lg" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>Create Resource</SheetTitle>
</SheetHeader>
<Form_Shadcn_ {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="overflow-auto flex-grow px-0"
>
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Name" description="A descriptive name">
<FormControl_Shadcn_ className="col-span-6 min-w-100">
<Input_Shadcn_ {...field} placeholder="Enter name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
</form>
</Form_Shadcn_>
<SheetFooter>
<Button type="default" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="primary" form={formId} htmlType="submit">
Create
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
```
### Common Form Field Types
- **Text Input**: `Input_Shadcn_` with `placeholder`
- **Password Input**: `Input_Shadcn_` with `type="password"`
- **Number Input**: `Input_Shadcn_` with `type="number"` and `onChange={(e) => field.onChange(Number(e.target.value))}`
- **Input with Units**: Wrap `Input_Shadcn_` with `PrePostTab` component: `<PrePostTab postTab="MB"><Input_Shadcn_ /></PrePostTab>`
- **Textarea**: `Textarea` component with `rows` and `className="resize-none"`
- **Switch**: `Switch` with `checked={field.value} onCheckedChange={field.onChange}`
- **Checkbox**: `Checkbox_Shadcn_` with label, use multiple for checkbox groups
- **Select**: `Select_Shadcn_` with `SelectTrigger_Shadcn_`, `SelectContent_Shadcn_`, `SelectItem_Shadcn_`
- **Multi-Select**: Use `MultiSelector` from `ui-patterns/multi-select`
- **Radio Group**: `RadioGroupStacked` with `RadioGroupStackedItem` for stacked options with descriptions
- **Date Picker**: `Calendar` inside `Popover_Shadcn_` with a trigger button
- **Copyable Input**: Use `Input` from `ui-patterns/DataInputs/Input` with `copy` and `readOnly` props
- **Field Array**: Use `useFieldArray` from `react-hook-form` for dynamic add/remove fields
- **Action Field**: Use `FormItemLayout` without form control, just buttons for navigation or performable actions. Wrap buttons in a div with `justify-end` to align them to the right
## Cards
- Use cards when needing to group related pieces of information
@@ -203,6 +346,11 @@ export function ProfileForm() {
## Sheets
- Use a sheet when needing to reveal more complicated forms or information relating to an object and context switching away to a new page would be disruptive e.g. we list auth providers, clicking an auth provider opens a sheet with information about that provider and a form to enable, user can close sheet to go back to providers list
- Use `SheetContent` with `size="lg"` for forms that need horizontal layout
- Use `SheetHeader`, `SheetTitle`, `SheetSection`, and `SheetFooter` for consistent structure
- Place submit/cancel buttons in `SheetFooter`
- For forms in sheets, use `FormItemLayout` with `layout="horizontal"` for wider panels or `layout="vertical"` for narrow panels (size="sm" or below)
- See the Forms section for a complete side panel form example
## React Query
@@ -223,7 +371,6 @@ export function ProfileForm() {
```jsx
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from 'ui'
;<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
+5 -5
View File
@@ -96,11 +96,11 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
<tr>
<td>JavaScript (TypeScript)</td>
<td><a href="https://github.com/supabase/supabase-js" target="_blank" rel="noopener noreferrer">supabase-js</a></td>
<td><a href="https://github.com/supabase/postgrest-js" target="_blank" rel="noopener noreferrer">postgrest-js</a></td>
<td><a href="https://github.com/supabase/gotrue-js" target="_blank" rel="noopener noreferrer">gotrue-js</a></td>
<td><a href="https://github.com/supabase/realtime-js" target="_blank" rel="noopener noreferrer">realtime-js</a></td>
<td><a href="https://github.com/supabase/storage-js" target="_blank" rel="noopener noreferrer">storage-js</a></td>
<td><a href="https://github.com/supabase/functions-js" target="_blank" rel="noopener noreferrer">functions-js</a></td>
<td><a href="https://github.com/supabase/supabase-js/tree/master/packages/core/postgrest-js" target="_blank" rel="noopener noreferrer">postgrest-js</a></td>
<td><a href="https://github.com/supabase/supabase-js/tree/master/packages/core/auth-js" target="_blank" rel="noopener noreferrer">auth-js</a></td>
<td><a href="https://github.com/supabase/supabase-js/tree/master/packages/core/realtime-js" target="_blank" rel="noopener noreferrer">realtime-js</a></td>
<td><a href="https://github.com/supabase/supabase-js/tree/master/packages/core/storage-js" target="_blank" rel="noopener noreferrer">storage-js</a></td>
<td><a href="https://github.com/supabase/supabase-js/tree/master/packages/core/functions-js" target="_blank" rel="noopener noreferrer">functions-js</a></td>
</tr>
<tr>
<td>Flutter</td>
+88 -11
View File
@@ -38,17 +38,6 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"ConfirmDialog": {
name: "ConfirmDialog",
type: "components:fragment",
registryDependencies: undefined,
component: React.lazy(() => import("@/../../packages/ui-patterns/src/Dialogs/ConfirmDialog")),
source: "",
files: ["registry/default//Dialogs/ConfirmDialog.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"PageContainer": {
name: "PageContainer",
type: "components:fragment",
@@ -2986,5 +2975,93 @@ export const Index: Record<string, any> = {
subcategory: "Composed",
chunks: []
},
"copy-button-verbs": {
name: "copy-button-verbs",
type: "components:example",
registryDependencies: ["button"],
component: React.lazy(() => import("@/registry/default/example/copy-button-verbs")),
source: "",
files: ["registry/default/example/copy-button-verbs.tsx"],
category: "Getting Started",
subcategory: "Copywriting",
chunks: []
},
"copy-form-labels": {
name: "copy-form-labels",
type: "components:example",
registryDependencies: ["form"],
component: React.lazy(() => import("@/registry/default/example/copy-form-labels")),
source: "",
files: ["registry/default/example/copy-form-labels.tsx"],
category: "Getting Started",
subcategory: "Copywriting",
chunks: []
},
"copy-error-messages": {
name: "copy-error-messages",
type: "components:example",
registryDependencies: ["form"],
component: React.lazy(() => import("@/registry/default/example/copy-error-messages")),
source: "",
files: ["registry/default/example/copy-error-messages.tsx"],
category: "Getting Started",
subcategory: "Copywriting",
chunks: []
},
"copy-success-messages": {
name: "copy-success-messages",
type: "components:example",
registryDependencies: ["form"],
component: React.lazy(() => import("@/registry/default/example/copy-success-messages")),
source: "",
files: ["registry/default/example/copy-success-messages.tsx"],
category: "Getting Started",
subcategory: "Copywriting",
chunks: []
},
"copy-tooltips": {
name: "copy-tooltips",
type: "components:example",
registryDependencies: ["tooltip"],
component: React.lazy(() => import("@/registry/default/example/copy-tooltips")),
source: "",
files: ["registry/default/example/copy-tooltips.tsx"],
category: "Getting Started",
subcategory: "Copywriting",
chunks: []
},
"copy-loading-states": {
name: "copy-loading-states",
type: "components:example",
registryDependencies: ["loading-state"],
component: React.lazy(() => import("@/registry/default/example/copy-loading-states")),
source: "",
files: ["registry/default/example/copy-loading-states.tsx"],
category: "Getting Started",
subcategory: "Copywriting",
chunks: []
},
"copy-empty-states": {
name: "copy-empty-states",
type: "components:example",
registryDependencies: ["empty-state"],
component: React.lazy(() => import("@/registry/default/example/copy-empty-states")),
source: "",
files: ["registry/default/example/copy-empty-states.tsx"],
category: "Getting Started",
subcategory: "Copywriting",
chunks: []
},
"copy-confirmations": {
name: "copy-confirmations",
type: "components:example",
registryDependencies: ["confirmation"],
component: React.lazy(() => import("@/registry/default/example/copy-confirmations")),
source: "",
files: ["registry/default/example/copy-confirmations.tsx"],
category: "Getting Started",
subcategory: "Copywriting",
chunks: []
},
},
}
@@ -4,7 +4,7 @@ import { SourcePanel } from '@/components/source-panel'
import { DashboardTableOfContents } from '@/components/toc'
import { siteConfig } from '@/config/site'
import { getTableOfContents } from '@/lib/toc'
import { absoluteUrl, cn } from '@/lib/utils'
import { absoluteUrl } from '@/lib/utils'
import '@/styles/code-block-variables.css'
import '@/styles/mdx.css'
import { allDocs } from 'contentlayer/generated'
@@ -83,15 +83,15 @@ export default async function DocPage(props: DocPageProps) {
const toc = await getTableOfContents(doc.body.raw)
return (
<main className="relative lg:gap-10 xl:grid xl:grid-cols-[1fr_200px] pr-6 lg:py-8">
<div className="mx-auto w-full min-w-0 max-w-4xl">
<div className="relative xl:grid xl:grid-cols-[1fr_160px] gap-4 px-6 py-6 lg:py-8">
<div className="mx-auto w-full min-w-0 max-w-4xl flex-1">
<div className="mb-4 flex items-center space-x-1 text-sm text-foreground-muted">
<div className="overflow-hidden text-ellipsis whitespace-nowrap">Docs</div>
<ChevronRight className="h-4 w-4 text-foreground-muted" />
<div className="text-foreground-lighter">{doc.title}</div>
</div>
<div className="space-y-2 mb-5">
<h1 className={cn('scroll-m-20 text-4xl tracking-tight')}>{doc.title}</h1>
<h1 className="scroll-m-20 text-4xl tracking-tight">{doc.title}</h1>
{doc.description && (
<p className="text-lg text-foreground-light">
<Balancer>{doc.description}</Balancer>
@@ -107,15 +107,15 @@ export default async function DocPage(props: DocPageProps) {
</div>
{doc.toc && (
<div className="hidden text-sm xl:block">
<div className="sticky top-16 -mt-10 pt-4">
<div className="sticky top-8 -mt-10 pt-8">
<ScrollArea className="pb-10">
<div className="sticky top-16 -mt-10 h-[calc(100vh-3.5rem)] py-12">
<div className="h-[calc(100vh-3.5rem)] py-14">
<DashboardTableOfContents toc={toc} />
</div>
</ScrollArea>
</div>
</div>
)}
</main>
</div>
)
}
+15 -21
View File
@@ -1,34 +1,28 @@
import SideNavigation from '@/components/side-navigation'
import { SideNavigation } from '@/components/side-navigation'
import { SiteFooter } from '@/components/site-footer'
// import ThemeSettings from '@/components/theme-settings'
import TopNavigation from '@/components/top-navigation'
import { TopNavigation } from '@/components/top-navigation'
import { MobileSidebarSheet } from '@/components/mobile-sidebar-sheet'
import { ScrollArea } from 'ui'
interface AppLayoutProps {
children: React.ReactNode
}
export default function AppLayout({ children }: AppLayoutProps) {
export default async function AppLayout({ children }: AppLayoutProps) {
return (
<>
<TopNavigation />
{/* main container */}
<div className="">
{/* main content */}
<main className="flex-1 max-w-site mx-auto w-full border-l border-r">
{/* {children} */}
<div className="border-b">
<div className="flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
<aside className="fixed top-10 z-30 hidden h-[calc(100vh-3rem)] w-full shrink-0 md:sticky md:block border-r">
<ScrollArea className="h-full py-6 lg:py-8">
<SideNavigation />
</ScrollArea>
</aside>
{children}
</div>
</div>
</main>
</div>
<MobileSidebarSheet />
<main className="flex-1 max-w-site mx-auto w-full border-l border-r border-b">
<div className="flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] lg:grid-cols-[240px_minmax(0,1fr)]">
<aside className="fixed top-10 z-30 hidden h-[calc(100vh-3rem)] w-full shrink-0 md:sticky md:block border-r">
<ScrollArea className="h-full">
<SideNavigation />
</ScrollArea>
</aside>
{children}
</div>
</main>
<SiteFooter />
</>
)
+52 -72
View File
@@ -1,85 +1,59 @@
import { HomepageSvgHandler } from '@/components/homepage-svg-handler'
import Link from 'next/link'
import { Paintbrush } from 'lucide-react'
import { Realtime, Database, Auth } from 'icons/src/icons'
export default function Home() {
return (
<main className="relative lg:gap-10 pr-6 lg:py-20">
<div className="mx-auto w-full min-w-0 max-w-6xl flex flex-col gap-20">
<div className="flex flex-col gap-8 justify-start">
{/* <div>
<DesignSystemMarks />
</div> */}
<div>
<h1 className="text-4xl text-foreground mb-3">Supabase Design System</h1>
<h2 className="text-xl text-foreground-light font-light">
Design resources for building consistent user experiences
</h2>
</div>
<div className="relative gap-4 px-6 py-6 lg:py-8">
<div className="mx-auto w-full min-w-0 max-w-4xl flex-1">
<div className="flex flex-col gap-2 justify-start my-8 w-full">
<h1 className="text-2xl lg:text-4xl text-foreground">Supabase Design System</h1>
<h2 className="md:text-xl text-base text-foreground-light font-light">
Design resources for building consistent user experiences
</h2>
</div>
{/* Homepage items */}
<div className="grid grid-cols-2 gap-10">
<Link href="/docs/components/atom-components">
<div className="px-10 py-8 min-h-[18rem] flex flex-col justify-between bg-surface-75/50 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<HomepageSvgHandler name="atoms-illustration" />
<div className="grid md:grid-cols-2 grid-cols-1 gap-4 w-full">
<Link href="/docs/components/atom-components" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex items-center justify-start min-h-[24px]">
<HomepageSvgHandler name="atoms" className="w-6 h-6" />
</div>
<div>
<h3 className="font-medium text-foreground">Atom components</h3>
<p className="text-sm text-foreground-light">Building blocks of User interfaces</p>
</div>
</div>
</Link>
<Link href="/docs/components/fragment-components">
<div className="px-10 py-8 min-h-[18rem] flex flex-col justify-between bg-surface-75/50 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<HomepageSvgHandler name="fragments-illustration" />
<Link href="/docs/fragments/fragment-components" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex items-center justify-start min-h-[24px]">
<HomepageSvgHandler name="fragments" className="w-6 h-6" />
</div>
<div>
<h3 className="font-medium text-foreground">Fragment components</h3>
<p className="text-sm text-foreground-light">Components assembled from Atoms</p>
</div>
</div>
</Link>
<Link href="/docs/color-usage">
<div className="px-10 py-8 min-h-[18rem] flex flex-col justify-between bg-surface-75/50 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex flex-col gap-1">
<div className="flex flex-row gap-0.5">
<div className="w-3 h-16 rounded border bg-foreground"></div>
<div className="w-3 h-16 rounded border bg-foreground-light"></div>
<div className="w-3 h-16 rounded border bg-foreground-lighter"></div>
<div className="w-3 h-16 rounded border bg-foreground-muted"></div>
<div className="w-3 h-16 rounded border"></div>
<div className="w-3 h-16 rounded border bg"></div>
<div className="w-3 h-16 rounded border bg-200"></div>
<div className="w-3 h-16 rounded border bg-surface-75"></div>
<div className="w-3 h-16 rounded border bg-surface-100"></div>
<div className="w-3 h-16 rounded border bg-surface-200"></div>
<div className="w-3 h-16 rounded border bg-surface-300"></div>
<div className="w-3 h-16 rounded border"></div>
<div className="w-3 h-16 rounded border bg-brand-200"></div>
<div className="w-3 h-16 rounded border bg-brand-300"></div>
<div className="w-3 h-16 rounded border bg-brand-400"></div>
<div className="w-3 h-16 rounded border bg-brand-500"></div>
<div className="w-3 h-16 rounded border bg-brand"></div>
<div className="w-3 h-16 rounded border bg-brand-600"></div>
<div className="w-3 h-16 rounded border"></div>
<div className="w-3 h-16 rounded border bg-warning-200"></div>
<div className="w-3 h-16 rounded border bg-warning-300"></div>
<div className="w-3 h-16 rounded border bg-warning-400"></div>
<div className="w-3 h-16 rounded border bg-warning-500"></div>
<div className="w-3 h-16 rounded border bg-warning"></div>
<div className="w-3 h-16 rounded border bg-warning-600"></div>
<div className="w-3 h-16 rounded border"></div>
<div className="w-3 h-16 rounded border bg-destructive-200"></div>
<div className="w-3 h-16 rounded border bg-destructive-300"></div>
<div className="w-3 h-16 rounded border bg-destructive-400"></div>
<div className="w-3 h-16 rounded border bg-destructive-500"></div>
<div className="w-3 h-16 rounded border bg-destructive"></div>
<div className="w-3 h-16 rounded border bg-destructive-600"></div>
<div className="w-3 h-16 rounded border"></div>
</div>
<Link href="/docs/components/ui-patterns" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex items-center justify-start min-h-[24px]">
<HomepageSvgHandler name="ui-patterns" className="w-6 h-6" />
</div>
<div>
<h3 className="font-medium text-foreground">UI Patterns components</h3>
<p className="text-sm text-foreground-light">
Components assembled from Atoms &amp; Fragments
</p>
</div>
</div>
</Link>
<Link href="/docs/color-usage" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex flex-col gap-2 min-h-[24px]">
<HomepageSvgHandler name="colours" className="w-6 h-6" />
</div>
<div>
<h3 className="font-medium text-foreground">Colors</h3>
@@ -88,27 +62,33 @@ export default function Home() {
</div>
</Link>
<Link href="/docs/theming">
<div className="px-10 py-8 min-h-[18rem] flex flex-col justify-between bg-surface-75/50 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<HomepageSvgHandler name="fragments-illustration" />
<Link href="/docs/theming" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex items-center justify-start min-h-[24px] text-brand">
<Paintbrush className="w-6 h-6" strokeWidth={1.5} stroke="currentColor" />
</div>
<div>
<h3 className="font-medium text-foreground">Theming</h3>
<p className="text-sm text-foreground-light">Components assembled from Atoms</p>
<p className="text-sm text-foreground-light">Simple extensible theming system</p>
</div>
</div>
</Link>
<Link href="/docs/icons">
<div className="px-10 py-8 min-h-[18rem] flex flex-col justify-between bg-surface-75/50 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<HomepageSvgHandler name="fragments-illustration" />
<Link href="/docs/icons" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex items-center justify-start min-h-[24px] gap-3 text-brand">
<Realtime className="w-5 h-5" strokeWidth={1.5} stroke="currentColor" />
<Database className="w-5 h-5 opacity-60" strokeWidth={1.5} stroke="currentColor" />
<Auth className="w-5 h-5 opacity-30" strokeWidth={1.5} stroke="currentColor" />
</div>
<div>
<h3 className="font-medium text-foreground">Icons</h3>
<p className="text-sm text-foreground-light">Components assembled from Atoms</p>
<p className="text-sm text-foreground-light">Custom icons for Supabase</p>
</div>
</div>
</Link>
</div>
</div>
</main>
</div>
)
}
+4 -1
View File
@@ -5,12 +5,15 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { ThemeProviderProps } from 'next-themes/dist/types'
import { TooltipProvider } from 'ui'
import { MobileSidebarProvider } from '@/context/mobile-sidebar-context'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<JotaiProvider>
<NextThemesProvider {...props}>
<TooltipProvider delayDuration={0}>{children}</TooltipProvider>
<TooltipProvider delayDuration={0}>
<MobileSidebarProvider>{children}</MobileSidebarProvider>
</TooltipProvider>
</NextThemesProvider>
</JotaiProvider>
)
+11 -19
View File
@@ -1,6 +1,6 @@
'use client'
import { CircleIcon, LaptopIcon, MoonIcon, SunIcon } from 'lucide-react'
import { CircleIcon, LaptopIcon, MoonIcon, SunIcon, Search } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useRouter } from 'next/navigation'
import * as React from 'react'
@@ -17,6 +17,7 @@ import {
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
DialogProps,
DialogTitle,
} from 'ui'
export function CommandMenu({ ...props }: DialogProps) {
@@ -55,8 +56,7 @@ export function CommandMenu({ ...props }: DialogProps) {
<Button
type="outline"
className={cn(
`relative h-8 w-full justify-start rounded-[0.5rem] bg-background text-sm font-normal text-foreground-muted shadow-none sm:pr-12 md:w-40 lg:w-64
hover:border-foreground-muted hover:bg-surface-100 hover:text-foreground-lighter
`lg:flex hidden relative h-8 w-full justify-start rounded-[0.5rem] bg-background text-sm font-normal text-foreground-muted shadow-none sm:pr-12 md:w-40 lg:w-64 hover:border-foreground-muted hover:bg-surface-100 hover:text-foreground-lighter
`
)}
onClick={() => setOpen(true)}
@@ -68,26 +68,18 @@ export function CommandMenu({ ...props }: DialogProps) {
<span className="text-sm">⌘</span>K
</kbd>
</Button>
<Button
type="text"
size="tiny"
className="px-1 group lg:hidden"
onClick={() => setOpen(true)}
icon={<Search size={16} />}
/>
<CommandDialog open={open} onOpenChange={setOpen}>
<DialogTitle className="sr-only">Search Design System...</DialogTitle>
<CommandInput_Shadcn_ placeholder="Type a command or search..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No results found.</CommandEmpty_Shadcn_>
{/* <CommandGroup_Shadcn_ heading="Links">
{docsConfig.mainNav
.filter((navitem) => !navitem.external)
.map((navItem) => (
<CommandItem_Shadcn_
key={navItem.href}
value={navItem.title}
onSelect={() => {
runCommand(() => router.push(navItem.href as string))
}}
>
<FileIcon className="mr-2 h-4 w-4" strokeWidth={1} />
{navItem.title}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_> */}
{docsConfig.sidebarNav.map((group) => (
<CommandGroup_Shadcn_ key={group.title} heading={group.title}>
{group.items.map((navItem) => (
@@ -23,6 +23,7 @@ interface ComponentPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
showGrid?: boolean
showDottedGrid?: boolean
wide?: boolean
hideCode?: boolean
}
export function ComponentPreview({
@@ -36,6 +37,7 @@ export function ComponentPreview({
showGrid = false,
showDottedGrid = true,
wide = false,
hideCode = false,
...props
}: ComponentPreviewProps) {
const [config] = useConfig()
@@ -136,7 +138,10 @@ export function ComponentPreview({
return (
<div className={cn('mt-4 mb-12', wideClasses)}>
<div
className={cn('relative rounded-tl-md rounded-tr-md border-t border-l border-r bg-studio')}
className={cn('relative bg-studio', {
'rounded-tl-md rounded-tr-md border-t border-l border-r': !hideCode,
'rounded-md border': hideCode,
})}
>
{showGrid && (
<div className="pointer-events-none absolute h-full w-full bg-[linear-gradient(to_right,hsla(var(--foreground-default)/0.02)_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
@@ -146,42 +151,44 @@ export function ComponentPreview({
)}
<div className="z-10 relative">{ComponentPreview}</div>
</div>
<Collapsible_Shadcn_>
<CollapsibleTrigger_Shadcn_
className={`
flex
gap-3 items-center
w-full
font-mono
text-xs
text-foreground-light
px-4 py-4
border border-r
group
data-[state=closed]:rounded-bl-md data-[state=closed]:rounded-br-md
`}
>
<ChevronRight
className="transition-all group-data-[state=open]:rotate-90 text-foreground-lighter"
size={14}
/>
View code
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_ className="transition-all">
<div
className={cn(
'relative',
'w-full rounded-md [&_pre]:my-0',
'[&_pre]:overflow-auto',
'[&_pre]:max-h-[320px]',
'[&_pre]:rounded-tr-none [&_pre]:rounded-tl-none [&_pre]:border-t-transparent'
)}
{!hideCode && (
<Collapsible_Shadcn_>
<CollapsibleTrigger_Shadcn_
className={`
flex
gap-3 items-center
w-full
font-mono
text-xs
text-foreground-light
px-4 py-4
border border-r
group
data-[state=closed]:rounded-bl-md data-[state=closed]:rounded-br-md
`}
>
{Code}
</div>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
<ChevronRight
className="transition-all group-data-[state=open]:rotate-90 text-foreground-lighter"
size={14}
/>
View code
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_ className="transition-all">
<div
className={cn(
'relative',
'w-full rounded-md [&_pre]:my-0',
'[&_pre]:overflow-auto',
'[&_pre]:max-h-[320px]',
'[&_pre]:rounded-tr-none [&_pre]:rounded-tl-none [&_pre]:border-t-transparent'
)}
>
{Code}
</div>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
)}
</div>
)
}
@@ -0,0 +1,20 @@
'use client'
import { SideNavigation } from '@/components/side-navigation'
import { useMobileSidebar } from '@/hooks/use-mobile-sidebar'
import { Sheet, SheetContent, SheetTitle, ScrollArea } from 'ui'
export function MobileSidebarSheet() {
const { open, setOpen } = useMobileSidebar()
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="left" className="p-0 w-[240px] sm:w-[280px]" showClose={false}>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<ScrollArea className="h-full py-6 lg:py-8">
<SideNavigation />
</ScrollArea>
</SheetContent>
</Sheet>
)
}
+3 -2
View File
@@ -4,7 +4,6 @@ import { NavItem, NavItemWithChildren } from '@/types/nav'
import { docsConfig } from '@/config/docs'
import { cn } from '@/lib/utils'
import { buttonVariants } from 'ui'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface DocsPagerProps {
@@ -18,8 +17,10 @@ export function DocsPager({ doc }: DocsPagerProps) {
return null
}
const hasBoth = pager?.prev?.href && pager?.next?.href
return (
<div className="flex flex-row items-center justify-between">
<div className={cn('flex flex-row items-center', hasBoth ? 'justify-between' : 'justify-end')}>
{pager?.prev?.href && (
<Link
href={pager.prev.href}
@@ -5,15 +5,22 @@ import { cn } from 'ui/src/lib/utils/cn'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import React from 'react'
import { useMobileSidebar } from '@/hooks/use-mobile-sidebar'
const NavigationItem: React.FC<{ item: SidebarNavItem }> = React.memo(({ item }) => {
export const NavigationItem: React.FC<{ item: SidebarNavItem }> = React.memo(({ item }) => {
const pathname = usePathname()
const { setOpen } = useMobileSidebar()
const isActive = pathname === item.href
const handleClick = () => {
setOpen(false)
}
return (
<Link
href={`${item.href}`}
onClick={handleClick}
className={cn(
'relative',
'flex',
@@ -40,5 +47,3 @@ const NavigationItem: React.FC<{ item: SidebarNavItem }> = React.memo(({ item })
})
NavigationItem.displayName = 'NavigationItem'
export default NavigationItem
@@ -1,9 +1,9 @@
import NavigationItem from '@/components/side-navigation-item'
import { NavigationItem } from '@/components/side-navigation-item'
import { docsConfig } from '@/config/docs'
function SideNavigation() {
export const SideNavigation = () => {
return (
<nav className="min-w-[220px]">
<nav className="min-w-[220px] py-6 lg:py-8">
{docsConfig.sidebarNav.map((section, i) => (
<div key={`${section.title}-${i}`} className="pb-10 space-y-0.5">
<div className="font-mono uppercase text-xs text-foreground-lighter/75 mb-2 px-6 tracking-widest">
@@ -26,5 +26,3 @@ function SideNavigation() {
</nav>
)
}
export default SideNavigation
@@ -2,8 +2,8 @@ import { siteConfig } from '@/config/site'
export function SiteFooter() {
return (
<footer className="py-6 md:px-8 md:py-0 mx-auto border-r border-l border-b w-full max-w-site">
<div className="flex flex-col items-center justify-between gap-4 md:h-24 md:flex-row">
<footer className="py-6 px-6 mx-auto border-r border-l border-b w-full max-w-site">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<p className="text-balance text-center text-sm leading-loose text-foreground-muted md:text-left">
Built by{' '}
<a
@@ -3,7 +3,6 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import SVG from 'react-inlinesvg'
import {
Button,
DropdownMenu,
@@ -13,8 +12,6 @@ import {
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
RadioGroupLargeItem_Shadcn_,
RadioGroup_Shadcn_,
Theme,
singleThemes,
} from 'ui'
@@ -36,22 +33,7 @@ const ThemeSwitcherDropdown = () => {
return null
}
function SingleThemeSelection() {
return (
<form>
<RadioGroup_Shadcn_
name="theme"
onValueChange={setTheme}
aria-label="Choose a theme"
defaultValue={theme}
value={theme}
className="flex flex-wrap gap-3"
></RadioGroup_Shadcn_>
</form>
)
}
const iconClasses = 'text-foreground-light group-data-[state=open]:text-foreground'
const iconClasses = 'text-foreground-lighter group-data-[state=open]:text-foreground'
return (
<>
@@ -68,7 +50,7 @@ const ThemeSwitcherDropdown = () => {
<Moon className={iconClasses} />
)
}
></Button>
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel>Theme</DropdownMenuLabel>
@@ -1,30 +1,37 @@
// import { docsConfig } from '@/config/docs'
'use client'
import Link from 'next/link'
import { Menu } from 'lucide-react'
import { Button } from 'ui'
import { CommandMenu } from './command-menu'
import { HomepageSvgHandler } from './homepage-svg-handler'
import { ThemeSwitcherDropdown } from './theme-switcher-dropdown'
import { useMobileSidebar } from '@/hooks/use-mobile-sidebar'
export const TopNavigation = () => {
const { toggle } = useMobileSidebar()
function TopNavigation() {
return (
<header className="sticky top-0 z-50 w-full border-t bg-studio/95 backdrop-blur supports-[backdrop-filter]:bg-studio/60">
<div className="absolute border-b border-dashed w-full top-[3.4rem] -z-10"></div>
<nav className="h-14 w-full flex">
<div className="max-w-site border-b w-full flex flex-row items-center gap-6 mx-auto px-6 border-r border-l justify-between">
<div className="flex items-center gap-8">
<Link href="/">
<h1>Supabase Design System</h1>
<header className="sticky top-0 z-50 w-full bg-studio/95 backdrop-blur supports-[backdrop-filter]:bg-studio/60 border-b border-l border-r">
<nav className="py-3 w-full flex">
<div className="max-w-site w-full flex flex-row items-center gap-6 mx-auto md:px-6 px-4 justify-between">
<div className="flex items-center gap-2">
<Button
type="text"
size="tiny"
className="px-1 md:hidden"
onClick={toggle}
icon={<Menu size={16} />}
aria-label="Toggle navigation menu"
/>
<Link href="/" className="flex items-center lg:gap-6 gap-4">
<h1 className="hidden md:flex line-clamp-1 lg:text-2xl text-xl">
Supabase Design System
</h1>
<HomepageSvgHandler name="logo" className="h-4 w-auto" />
</Link>
<HomepageSvgHandler name="design-system-marks" className="h-4 w-auto" />
</div>
{/* {docsConfig.mainNav.map((section) => (
<>
<div className="font-mono uppercase text-xs text-foreground-lighter">
{section.title}
</div>
</>
))} */}
<div className="flex items-center gap-8">
{/* <TopNavigationSearch /> */}
<div className="flex items-center gap-2">
<CommandMenu />
<ThemeSwitcherDropdown />
</div>
@@ -33,5 +40,3 @@ function TopNavigation() {
</header>
)
}
export default TopNavigation
+152 -145
View File
@@ -9,31 +9,28 @@ export const docsConfig: DocsConfig = {
sidebarNav: [
{
title: 'Getting Started',
sortOrder: 'manual',
sortOrder: 'alphabetical',
items: [
{
title: 'Introduction',
href: '/docs',
priority: true,
items: [],
},
{
title: 'Tailwind Classes',
href: '/docs/tailwind-classes',
title: 'Accessibility',
href: '/docs/accessibility',
items: [],
},
{
title: 'Color Usage',
href: '/docs/color-usage',
items: [],
},
{
title: 'Typography',
href: '/docs/typography',
items: [],
},
{
title: 'Theming',
href: '/docs/theming',
title: 'Copywriting',
href: '/docs/copywriting',
items: [],
},
{
@@ -42,147 +39,18 @@ export const docsConfig: DocsConfig = {
items: [],
},
{
title: 'Accessibility',
href: '/docs/accessibility',
items: [],
},
],
},
{
title: 'UI Patterns',
sortOrder: 'alphabetical',
items: [
{
title: 'Introduction',
href: '/docs/ui-patterns/ui-patterns',
items: [],
priority: true,
},
{
title: 'Charts',
href: '/docs/ui-patterns/charts',
title: 'Tailwind Classes',
href: '/docs/tailwind-classes',
items: [],
},
{
title: 'Empty States',
href: '/docs/ui-patterns/empty-states',
title: 'Theming',
href: '/docs/theming',
items: [],
},
{
title: 'Forms',
href: '/docs/ui-patterns/forms',
items: [],
},
{
title: 'Layout',
href: '/docs/ui-patterns/layout',
items: [],
},
{
title: 'Navigation',
href: '/docs/ui-patterns/navigation',
items: [],
},
{
title: 'Tables',
href: '/docs/ui-patterns/tables',
items: [],
},
],
},
{
title: 'Fragment Components',
sortOrder: 'alphabetical',
items: [
{
title: 'Introduction',
href: '/docs/fragments/fragment-components',
items: [],
priority: true,
},
{
title: 'Admonition',
href: '/docs/fragments/admonition',
items: [],
},
{
title: 'Assistant Chat',
href: '/docs/fragments/assistant-chat',
items: [],
},
{
title: 'EmptyStatePresentational',
href: '/docs/fragments/empty-state-presentational',
items: [],
},
{
title: 'Modal',
href: '/docs/fragments/modal',
items: [],
},
{
title: 'Page Container',
href: '/docs/fragments/page-container',
items: [],
},
{
title: 'Page Header',
href: '/docs/fragments/page-header',
items: [],
},
{
title: 'Page Section',
href: '/docs/fragments/page-section',
items: [],
},
{
title: 'Text Confirm Dialog',
href: '/docs/fragments/text-confirm-dialog',
items: [],
},
{
title: 'Info Tooltip',
href: '/docs/fragments/info-tooltip',
items: [],
},
{
title: 'Inner Side Menu',
href: '/docs/fragments/inner-side-menu',
items: [],
},
{
title: 'Data Input',
href: '/docs/fragments/data-input',
items: [],
},
{
title: 'Form Item Layout',
href: '/docs/fragments/form-item-layout',
items: [],
},
{
title: 'Multi Select',
href: '/docs/fragments/multi-select',
items: [],
},
{
title: 'Filter Bar',
href: '/docs/fragments/filter-bar',
items: [],
},
{
title: 'Logs Bar Chart',
href: '/docs/fragments/logs-bar-chart',
items: [],
},
{
title: 'Metric Card',
href: '/docs/fragments/metric-card',
items: [],
},
{
title: 'Table of Contents (TOC)',
href: '/docs/fragments/toc',
title: 'Typography',
href: '/docs/typography',
items: [],
},
],
@@ -477,5 +345,144 @@ export const docsConfig: DocsConfig = {
},
],
},
{
title: 'Fragment Components',
sortOrder: 'alphabetical',
items: [
{
title: 'Introduction',
href: '/docs/fragments/fragment-components',
items: [],
priority: true,
},
{
title: 'Admonition',
href: '/docs/fragments/admonition',
items: [],
},
{
title: 'Assistant Chat',
href: '/docs/fragments/assistant-chat',
items: [],
},
{
title: 'EmptyStatePresentational',
href: '/docs/fragments/empty-state-presentational',
items: [],
},
{
title: 'Modal',
href: '/docs/fragments/modal',
items: [],
},
{
title: 'Page Container',
href: '/docs/fragments/page-container',
items: [],
},
{
title: 'Page Header',
href: '/docs/fragments/page-header',
items: [],
},
{
title: 'Page Section',
href: '/docs/fragments/page-section',
items: [],
},
{
title: 'Text Confirm Dialog',
href: '/docs/fragments/text-confirm-dialog',
items: [],
},
{
title: 'Info Tooltip',
href: '/docs/fragments/info-tooltip',
items: [],
},
{
title: 'Inner Side Menu',
href: '/docs/fragments/inner-side-menu',
items: [],
},
{
title: 'Data Input',
href: '/docs/fragments/data-input',
items: [],
},
{
title: 'Form Item Layout',
href: '/docs/fragments/form-item-layout',
items: [],
},
{
title: 'Multi Select',
href: '/docs/fragments/multi-select',
items: [],
},
{
title: 'Filter Bar',
href: '/docs/fragments/filter-bar',
items: [],
},
{
title: 'Logs Bar Chart',
href: '/docs/fragments/logs-bar-chart',
items: [],
},
{
title: 'Metric Card',
href: '/docs/fragments/metric-card',
items: [],
},
{
title: 'Table of Contents (TOC)',
href: '/docs/fragments/toc',
items: [],
},
],
},
{
title: 'UI Patterns',
sortOrder: 'alphabetical',
items: [
{
title: 'Introduction',
href: '/docs/ui-patterns/ui-patterns',
items: [],
priority: true,
},
{
title: 'Charts',
href: '/docs/ui-patterns/charts',
items: [],
},
{
title: 'Empty States',
href: '/docs/ui-patterns/empty-states',
items: [],
},
{
title: 'Forms',
href: '/docs/ui-patterns/forms',
items: [],
},
{
title: 'Layout',
href: '/docs/ui-patterns/layout',
items: [],
},
{
title: 'Navigation',
href: '/docs/ui-patterns/navigation',
items: [],
},
{
title: 'Tables',
href: '/docs/ui-patterns/tables',
items: [],
},
],
},
],
}
@@ -0,0 +1,240 @@
---
title: Copywriting
description: A concise guide for writing UI copy in Supabase.
---
Write UI copy that helps developers complete tasks quickly. Be direct, action-oriented, and respectful of developer time.
## Voice and tone
Supabase UI copy is:
- **Direct**: Say what something does, not what it "enables" you to do.
- **Action-oriented**: Focus on what happens, not what we built.
- **Technical without jargon**: Use precise terms but explain when necessary.
- **Pragmatic**: Acknowledge tradeoffs and limitations when relevant.
## Buttons and actions
### Use verbs, not nouns
<ComponentPreview name="copy-button-verbs" hideCode />
### Be specific about outcomes
| Bad | Good |
| ----------- | ---------------- |
| "Remove" | "Delete project" |
| "Change" | "Revoke access" |
| "Configure" | "Enable RLS" |
### Match button text to the action
| Action | Bad | Good |
| ----------------- | --------- | -------------- |
| Primary action: | "Submit" | "Create table" |
| Secondary action: | "Go back" | "Cancel" |
## Form labels and descriptions
### Labels describe the field, not the feature
<ComponentPreview name="copy-form-labels" hideCode />
| Action | Bad | Good |
| ------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------------- |
| Label: | "Name your table" | "Table name" |
| Description: | "This field allows you to specify a name for your table using letters, numbers, and underscores" | "Letters, numbers, and underscores only" |
### Descriptions explain constraints, not concepts
| Bad | Good |
| --------------------------------------------------------------- | ---------------------------------- |
| "This ensures your table name is unique" | "Must be unique within the schema" |
| "You can enter up to 255 characters here" | "Maximum 255 characters" |
| "This field is required when using Row Level Security policies" | "Required for RLS policies" |
### Use present tense
| Bad | Good |
| -------------------------------------- | --------------------------------- |
| "Will store connection pool settings" | "Stores connection pool settings" |
| "This will limit query execution time" | "Limits query execution time" |
## Error messages
### State what went wrong, then how to fix it
<ComponentPreview name="copy-error-messages" hideCode />
| Bad | Good |
| ----------------------------------------- | ----------------------------------------------------- |
| "An error occurred" | "Table name already exists. Choose a different name." |
| "Something went wrong. Please try again." | "Invalid API key. Check your project settings." |
### Be specific about the problem
| Bad | Good |
| ------------------ | --------------------------------------------- |
| "Invalid input" | "Password must be at least 8 characters" |
| "Connection error" | "Connection failed: timeout after 30 seconds" |
### Avoid blame or apology
| Bad | Good |
| ---------------------------- | ------------------------------------------------ |
| "Sorry, we couldn't connect" | "Unable to connect to database" |
| "Oops! Something went wrong" | "Table creation failed: column name is reserved" |
## Success messages
### Confirm what happened
<ComponentPreview name="copy-success-messages" hideCode />
| Bad | Good |
| --------------------- | ---------------------------- |
| "Success!" | "Table created successfully" |
| "Done" | "API key revoked" |
| "Operation completed" | "Changes saved" |
### Keep it brief
| Bad | Good |
| ------------------------------------------------------------- | ------------------- |
| "Your backup has been successfully restored to your database" | "Backup restored" |
| "The migration has been applied successfully to your project" | "Migration applied" |
## Tooltips and help text
### Explain why, not what
<ComponentPreview name="copy-tooltips" hideCode />
| Bad | Good |
| ------------------------- | ------------------------------------------------ |
| "This is a toggle switch" | "Enables real-time subscriptions for this table" |
| "Click to delete" | "Prevents accidental deletions" |
### One sentence maximum
| Bad | Good |
| ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |
| "Row Level Security restricts access based on user policies. When enabled, users can only access rows that match their policy conditions." | "Restricts access based on user policies" |
| "This setting controls the maximum number of concurrent connections that can be established to your database at any given time." | "Maximum number of concurrent connections" |
## Navigation and headings
### Use sentence case
| Bad | Good |
| ----------------------- | ----------------------- |
| "Set Up Authentication" | "Set up authentication" |
| "Database Settings" | "Database settings" |
| "Create New Project" | "Create new project" |
### Headings describe the page, not the feature
| Bad | Good |
| ------------------------------ | -------------------- |
| "Manage your API keys" | "API keys" |
| "Configure connection pooling" | "Connection pooling" |
| "Edit your tables" | "Table editor" |
## Empty states
### Explain what's missing, then how to add it
<ComponentPreview name="copy-empty-states" hideCode />
| Bad | Good |
| --------------------------------- | ---------------------------------------------------------- |
| "You don't have any tables" | "No tables yet. Create your first table to get started." |
| "There are no API keys available" | "No API keys. Generate a key to connect your application." |
### Include the action
| Bad | Good |
| ------------------------ | ------------------------------------------------- |
| "No buckets found" | "No buckets yet. [Create bucket] button" |
| "No functions available" | "No functions deployed. [Deploy function] button" |
## Loading states
### Describe what's happening
<ComponentPreview name="copy-loading-states" hideCode />
| Bad | Good |
| ---------------- | ----------------------- |
| "Please wait..." | "Creating table..." |
| "Loading..." | "Loading schema..." |
| "Processing..." | "Applying migration..." |
### Match the action verb
| Action | Bad | Good |
| --------------------------- | ---------------- | --------------------- |
| "Delete project" → Loading: | "Processing..." | "Deleting project..." |
| "Save changes" → Loading: | "Please wait..." | "Saving changes..." |
## Confirmations and dialogs
### State consequences clearly
<ComponentPreview name="copy-confirmations" hideCode />
| Bad | Good |
| ------------------------------------- | ----------------------------------------------------------------------------------------- |
| "Are you sure?" | "Delete this project? This action cannot be undone and will permanently delete all data." |
| "This action is permanent. Continue?" | "Revoke this API key? Applications using this key will stop working immediately." |
### Use active voice
| Bad | Good |
| ------------------------------------------------------------ | --------------------------------------------------- |
| "All data will be removed if this project is deleted" | "Deleting this project will remove all data" |
| "Existing connections will be broken if this key is revoked" | "Revoking this key will break existing connections" |
## Words to avoid
### Marketing language
| Bad | Good |
| ---------------------------- | -------------------- |
| "Easily create tables" | "Create tables" |
| "Simply configure settings" | "Configure settings" |
| "Powerful database features" | "Database features" |
### Vague verbs
| Bad | Good |
| ---------------- | -------------------------------- |
| "Manage tables" | "Create, edit, or delete tables" |
| "Handle errors" | "View and resolve errors" |
| "Work with data" | "Query and update data" |
## Capitalization
- **Sentence case** for all UI text (buttons, labels, headings)
- **Product names:** Database, Auth, Storage, Edge Functions, Realtime, Vector
- **Postgres**, not PostgreSQL
- **Supabase** (capitalize except in code)
## Formatting
- **Bold for emphasis** only when necessary
- **Inline code** for technical terms: `RLS`, `API key`, `supabase init`
- **No italics** for emphasis
- **No exclamation marks** unless critical (e.g., destructive actions)
## Quick checklist
Before publishing UI copy, ask:
- Does it use an action verb?
- Is it specific about what happens?
- Can a developer complete the task without reading more?
- Does it avoid marketing language?
- Is it in sentence case?
- Is it one sentence or less (for labels, buttons, tooltips)?
@@ -42,6 +42,11 @@ A [Table](../components/table) instance with zero results should display a singl
<ComponentPreview name="empty-state-zero-items-table" peekCode wide />
Studio contains two pre-built components to handle these cases consistently:
- No Filter Results
- No Search Results
#### Data Grid
[Data Grid](../ui-patterns/tables#data-grid) and [Data Table](../ui-patterns/tables#data-table) component patterns typically span the full height and width of a container. A classic example is [Users](https://supabase.com/dashboard/project/_/auth/users), which (as it sounds) displays a list of the project’s registered users. Any instance with zero results should display a more prominent empty with a clear title, description, and supporting illustration.
@@ -0,0 +1,34 @@
'use client'
import React, { ReactNode, useState, useCallback } from 'react'
import { MobileSidebarContext, MobileSidebarContextType } from '@/hooks/use-mobile-sidebar'
interface MobileSidebarProviderProps {
children: ReactNode
}
/**
* Provider component for mobile sidebar state
* Wraps children with context that provides mobile sidebar state and controls
*/
export function MobileSidebarProvider({ children }: MobileSidebarProviderProps) {
const [open, setOpen] = useState(false)
// Use useCallback for stable function references
const handleSetOpen = useCallback((value: boolean) => {
setOpen(value)
}, [])
const toggle = useCallback(() => {
setOpen((prev) => !prev)
}, [])
// Memoize the context value to prevent unnecessary re-renders
const value: MobileSidebarContextType = {
open,
setOpen: handleSetOpen,
toggle,
}
return <MobileSidebarContext.Provider value={value}>{children}</MobileSidebarContext.Provider>
}
+1 -1
View File
@@ -4,7 +4,7 @@ const supabaseConfig = require('eslint-config-supabase/next')
module.exports = defineConfig([
supabaseConfig,
{
files: ['registry/**/*.tsx', '__registry__/**/*.tsx'],
files: ['registry/**/*.tsx', '__registry__/**/*.tsx', 'app/**/*.tsx'],
rules: {
'no-restricted-exports': 'off',
},
@@ -0,0 +1,24 @@
'use client'
import { createContext, useContext } from 'react'
export interface MobileSidebarContextType {
open: boolean
setOpen: (open: boolean) => void
toggle: () => void
}
export const MobileSidebarContext = createContext<MobileSidebarContextType | null>(null)
export function useMobileSidebar(): MobileSidebarContextType {
const context = useContext(MobileSidebarContext)
if (context === null) {
throw new Error(
'useMobileSidebar must be used within a MobileSidebarProvider. ' +
'Ensure your component is wrapped with MobileSidebarProvider.'
)
}
return context
}
@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3733_538" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3733_538)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 48L48 0L0 48L48 96L96 48ZM7.54099 48L48 7.54133L88.459 48L48 88.4587L7.54099 48Z" fill="#B4B4B4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 459 B

@@ -0,0 +1 @@
<svg fill="none" height="96" viewBox="0 0 96 96" width="96" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><mask id="a" height="96" maskUnits="userSpaceOnUse" width="96" x="0" y="0"><path d="m96 0h-96v96h96z" fill="#fff"/></mask><g mask="url(#a)"><path clip-rule="evenodd" d="m96 48-48-48-48 48 48 48zm-88.45901 0 40.45901-40.45867 40.459 40.45867-40.459 40.4587z" fill="#16b674" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 442 B

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3733_544" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3733_544)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 48L48 0L0 48L48 96L96 48ZM7.54099 48L48 7.54133L88.459 48L48 88.4587L7.54099 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 459 B

@@ -0,0 +1,6 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 48L3.771 51.7707L17.227 65.2267L21.003 68.9973L24.773 65.2267L38.229 51.7707L42 48L38.229 44.2293L24.773 30.7733L21.003 27.0027L17.227 30.7733L3.771 44.2293L0 48Z" fill="#E54D2E"/>
<path d="M54 48L57.771 51.7707L71.227 65.2267L75.003 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#FFB224"/>
<path d="M27 74.9973L30.771 78.768L44.227 92.224L48.003 95.9946L51.773 92.224L65.229 78.768L69 74.9973L65.229 71.2266L51.773 57.7706L48.003 54L44.227 57.7706L30.771 71.2266L27 74.9973Z" fill="#16B674"/>
<path d="M27 20.9973L30.771 24.768L44.227 38.224L48.003 41.9946L51.773 38.224L65.229 24.768L69 20.9973L65.229 17.2266L51.773 3.7706L48.003 0L44.227 3.7706L30.771 17.2266L27 20.9973Z" fill="#A0A0A0"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

@@ -0,0 +1,6 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 48L3.771 51.7707L17.227 65.2267L21.003 68.9973L24.773 65.2267L38.229 51.7707L42 48L38.229 44.2293L24.773 30.7733L21.003 27.0027L17.227 30.7733L3.771 44.2293L0 48Z" fill="#E54D2E"/>
<path d="M54 48L57.771 51.7707L71.227 65.2267L75.003 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#FFB224"/>
<path d="M27 74.9973L30.771 78.768L44.227 92.224L48.003 95.9946L51.773 92.224L65.229 78.768L69 74.9973L65.229 71.2266L51.773 57.7706L48.003 54L44.227 57.7706L30.771 71.2266L27 74.9973Z" fill="#16B674"/>
<path d="M27 20.9973L30.771 24.768L44.227 38.224L48.003 41.9946L51.773 38.224L65.229 24.768L69 20.9973L65.229 17.2266L51.773 3.7706L48.003 0L44.227 3.7706L30.771 17.2266L27 20.9973Z" fill="#A0A0A0"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

@@ -0,0 +1,6 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 48L3.771 51.7707L17.227 65.2267L21.003 68.9973L24.773 65.2267L38.229 51.7707L42 48L38.229 44.2293L24.773 30.7733L21.003 27.0027L17.227 30.7733L3.771 44.2293L0 48Z" fill="#E54D2E"/>
<path d="M54 48L57.771 51.7707L71.227 65.2267L75.003 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#FFB224"/>
<path d="M27 74.9973L30.771 78.768L44.227 92.224L48.003 95.9946L51.773 92.224L65.229 78.768L69 74.9973L65.229 71.2266L51.773 57.7706L48.003 54L44.227 57.7706L30.771 71.2266L27 74.9973Z" fill="#16B674"/>
<path d="M27 20.9973L30.771 24.768L44.227 38.224L48.003 41.9946L51.773 38.224L65.229 24.768L69 20.9973L65.229 17.2266L51.773 3.7706L48.003 0L44.227 3.7706L30.771 17.2266L27 20.9973Z" fill="#1C1C1C"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3807" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3807)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#B4B4B4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3855" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3855)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3855" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3855)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,26 @@
<svg width="570" height="96" viewBox="0 0 570 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_75_339" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_75_339)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#B4B4B4"/>
</g>
<mask id="mask1_75_339" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="158" y="0" width="96" height="96">
<path d="M254 0H158V96H254V0Z" fill="white"/>
</mask>
<g mask="url(#mask1_75_339)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48Z" fill="#B4B4B4"/>
</g>
<mask id="mask2_75_339" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="316" y="0" width="96" height="96">
<path d="M412 0H316V96H412V0Z" fill="white"/>
</mask>
<g mask="url(#mask2_75_339)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48ZM323.541 48L364 7.54133L404.459 48L364 88.4587L323.541 48Z" fill="#B4B4B4"/>
</g>
<mask id="mask3_75_339" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="474" y="0" width="96" height="96">
<path d="M570 0H474V96H570V0Z" fill="white"/>
</mask>
<g mask="url(#mask3_75_339)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#B4B4B4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,26 @@
<svg width="570" height="96" viewBox="0 0 570 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_75_361" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_75_361)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#15593B"/>
</g>
<mask id="mask1_75_361" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="158" y="0" width="96" height="96">
<path d="M254 0H158V96H254V0Z" fill="white"/>
</mask>
<g mask="url(#mask1_75_361)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48Z" fill="#16B674"/>
</g>
<mask id="mask2_75_361" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="316" y="0" width="96" height="96">
<path d="M412 0H316V96H412V0Z" fill="white"/>
</mask>
<g mask="url(#mask2_75_361)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48ZM323.541 48L364 7.54133L404.459 48L364 88.4587L323.541 48Z" fill="#15593B"/>
</g>
<mask id="mask3_75_361" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="474" y="0" width="96" height="96">
<path d="M570 0H474V96H570V0Z" fill="white"/>
</mask>
<g mask="url(#mask3_75_361)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,26 @@
<svg width="570" height="96" viewBox="0 0 570 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_75_340" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_75_340)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#16B674"/>
</g>
<mask id="mask1_75_340" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="158" y="0" width="96" height="96">
<path d="M254 0H158V96H254V0Z" fill="white"/>
</mask>
<g mask="url(#mask1_75_340)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48Z" fill="#16B674"/>
</g>
<mask id="mask2_75_340" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="316" y="0" width="96" height="96">
<path d="M412 0H316V96H412V0Z" fill="white"/>
</mask>
<g mask="url(#mask2_75_340)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48ZM323.541 48L364 7.54133L404.459 48L364 88.4587L323.541 48Z" fill="#16B674"/>
</g>
<mask id="mask3_75_340" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="474" y="0" width="96" height="96">
<path d="M570 0H474V96H570V0Z" fill="white"/>
</mask>
<g mask="url(#mask3_75_340)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3812" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3812)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.003 21.0027L30.773 24.7733L44.229 38.2293L48 42L51.771 38.2293L65.227 24.7733L68.997 21.0027L65.227 17.2267L51.771 3.77067L48 0L44.229 3.77067L30.773 17.2267L27.003 21.0027ZM27.003 74.9973L30.773 78.7733L44.229 92.2293L48 96L51.771 92.2293L65.227 78.7733L68.997 75.0027L65.227 71.2267L51.771 57.7707L48 54L44.229 57.7707L30.773 71.2267L27.003 74.9973ZM3.771 51.7707L0 48L3.771 44.2293L17.227 30.7733L21.003 27.0027L24.773 30.7733L38.229 44.2293L42 48L38.229 51.7707L24.773 65.2267L21.003 68.9973L17.227 65.2267L3.771 51.7707ZM54 48L57.771 51.7707L71.227 65.2267L74.997 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#B4B4B4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3860" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3860)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.003 21.0027L30.773 24.7733L44.229 38.2293L48 42L51.771 38.2293L65.227 24.7733L68.997 21.0027L65.227 17.2267L51.771 3.77067L48 0L44.229 3.77067L30.773 17.2267L27.003 21.0027ZM27.003 74.9973L30.773 78.7733L44.229 92.2293L48 96L51.771 92.2293L65.227 78.7733L68.997 75.0027L65.227 71.2267L51.771 57.7707L48 54L44.229 57.7707L30.773 71.2267L27.003 74.9973ZM3.771 51.7707L0 48L3.771 44.2293L17.227 30.7733L21.003 27.0027L24.773 30.7733L38.229 44.2293L42 48L38.229 51.7707L24.773 65.2267L21.003 68.9973L17.227 65.2267L3.771 51.7707ZM54 48L57.771 51.7707L71.227 65.2267L74.997 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3860" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3860)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.003 21.0027L30.773 24.7733L44.229 38.2293L48 42L51.771 38.2293L65.227 24.7733L68.997 21.0027L65.227 17.2267L51.771 3.77067L48 0L44.229 3.77067L30.773 17.2267L27.003 21.0027ZM27.003 74.9973L30.773 78.7733L44.229 92.2293L48 96L51.771 92.2293L65.227 78.7733L68.997 75.0027L65.227 71.2267L51.771 57.7707L48 54L44.229 57.7707L30.773 71.2267L27.003 74.9973ZM3.771 51.7707L0 48L3.771 44.2293L17.227 30.7733L21.003 27.0027L24.773 30.7733L38.229 44.2293L42 48L38.229 51.7707L24.773 65.2267L21.003 68.9973L17.227 65.2267L3.771 51.7707ZM54 48L57.771 51.7707L71.227 65.2267L74.997 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,68 @@
import { Registry } from './schema'
export const copyWriting: Registry = [
{
name: 'copy-button-verbs',
type: 'components:example',
files: ['example/copy-button-verbs.tsx'],
registryDependencies: ['button'],
category: 'Getting Started',
subcategory: 'Copywriting',
},
{
name: 'copy-form-labels',
type: 'components:example',
files: ['example/copy-form-labels.tsx'],
registryDependencies: ['form'],
category: 'Getting Started',
subcategory: 'Copywriting',
},
{
name: 'copy-error-messages',
type: 'components:example',
files: ['example/copy-error-messages.tsx'],
registryDependencies: ['form'],
category: 'Getting Started',
subcategory: 'Copywriting',
},
{
name: 'copy-success-messages',
type: 'components:example',
files: ['example/copy-success-messages.tsx'],
registryDependencies: ['form'],
category: 'Getting Started',
subcategory: 'Copywriting',
},
{
name: 'copy-tooltips',
type: 'components:example',
files: ['example/copy-tooltips.tsx'],
registryDependencies: ['tooltip'],
category: 'Getting Started',
subcategory: 'Copywriting',
},
{
name: 'copy-loading-states',
type: 'components:example',
files: ['example/copy-loading-states.tsx'],
registryDependencies: ['loading-state'],
category: 'Getting Started',
subcategory: 'Copywriting',
},
{
name: 'copy-empty-states',
type: 'components:example',
files: ['example/copy-empty-states.tsx'],
registryDependencies: ['empty-state'],
category: 'Getting Started',
subcategory: 'Copywriting',
},
{
name: 'copy-confirmations',
type: 'components:example',
files: ['example/copy-confirmations.tsx'],
registryDependencies: ['confirmation'],
category: 'Getting Started',
subcategory: 'Copywriting',
},
]
@@ -0,0 +1,22 @@
'use client'
import { Button } from 'ui'
export default function CopyButtonVerbs() {
return (
<div className="flex flex-row gap-16">
<div className="flex flex-col gap-4">
<span className="text-xs text-foreground-muted">Bad Example</span>
<Button type="primary">Table creation</Button>
<Button type="primary">Save action</Button>
<Button type="danger">Bucket deletion</Button>
</div>
<div className="flex flex-col gap-4">
<span className="text-xs text-foreground-muted">Good Example</span>
<Button type="primary">Create table</Button>
<Button type="primary">Save changes</Button>
<Button type="danger">Delete bucket</Button>
</div>
</div>
)
}
@@ -0,0 +1,54 @@
'use client'
import { Button } from 'ui'
import { AlertTriangle } from 'lucide-react'
export default function CopyConfirmations() {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4 w-[400px]">
<span className="text-xs text-foreground-muted">Bad Example</span>
<div className="flex flex-col gap-5 border border-border rounded-md p-4 bg-surface-100">
<div className="flex flex-row items-start gap-4">
<div className="flex flex-col gap-1 flex-1">
<h3 className="text-sm font-medium text-foreground">Are you sure?</h3>
<p className="text-sm text-foreground-light">
All data will be removed if this project is deleted
</p>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button type="default" size="tiny">
Cancel
</Button>
<Button type="danger" size="tiny">
Delete
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-4 w-[400px]">
<span className="text-xs text-foreground-muted">Good Example</span>
<div className="flex flex-col gap-5 border border-border rounded-md p-4 bg-surface-100">
<div className="flex flex-row items-start gap-4">
<div className="flex flex-col gap-1 flex-1">
<h3 className="text-sm font-medium text-foreground">Delete this project?</h3>
<p className="text-sm text-foreground-light">
This action cannot be undone and will permanently delete all data. Deleting this
project will remove all data.
</p>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button type="default" size="tiny">
Cancel
</Button>
<Button type="danger" size="tiny">
Delete project
</Button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,34 @@
'use client'
import { Button } from 'ui'
import { EmptyStatePresentational } from 'ui-patterns'
import { Key } from 'lucide-react'
export default function CopyEmptyStates() {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<span className="text-xs text-foreground-muted">Bad Example</span>
<div className="flex flex-col gap-4">
<EmptyStatePresentational
icon={Key}
title="There are no API keys available"
description=""
/>
</div>
</div>
<div className="flex flex-col gap-4">
<span className="text-xs text-foreground-muted">Good Example</span>
<div className="flex flex-col gap-4">
<EmptyStatePresentational
icon={Key}
title="No API keys"
description="Generate a key to connect your application."
>
<Button type="primary">Generate key</Button>
</EmptyStatePresentational>
</div>
</div>
</div>
)
}
@@ -0,0 +1,24 @@
'use client'
import { CircleAlert } from 'lucide-react'
export default function CopyErrorMessages() {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4 w-[400px]">
<span className="text-xs text-foreground-muted">Bad Example</span>
<div className="flex flex-row items-center gap-2 border border-destructive-500 rounded-md px-4 py-2 bg-destructive-200 text-destructive shadow-md">
<CircleAlert size={16} strokeWidth={1.5} />
<p className="text-sm">Something went wrong. Please try again.</p>
</div>
</div>
<div className="flex flex-col gap-4 w-[400px]">
<span className="text-xs text-foreground-muted">Good Example</span>
<div className="flex flex-row items-center gap-2 border border-destructive-500 rounded-md px-4 py-2 bg-destructive-200 text-destructive shadow-md">
<CircleAlert size={16} strokeWidth={1.5} />
<p className="text-sm">Invalid API key. Check your project settings.</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,29 @@
'use client'
import { Input_Shadcn_, Label_Shadcn_ } from 'ui'
export default function CopyFormLabels() {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4 w-[300px]">
<span className="text-xs text-foreground-muted">Bad Example</span>
<div className="grid w-full items-center gap-1.5">
<Label_Shadcn_ htmlFor="table-name-bad">Name your table</Label_Shadcn_>
<Input_Shadcn_ id="table-name-bad" placeholder="my_table" />
<p className="text-sm text-muted-foreground">
This field allows you to specify a name for your table using letters, numbers, and
underscores
</p>
</div>
</div>
<div className="flex flex-col gap-4 w-[300px]">
<span className="text-xs text-foreground-muted">Good Example</span>
<div className="grid w-full items-center gap-1.5">
<Label_Shadcn_ htmlFor="table-name-good">Table name</Label_Shadcn_>
<Input_Shadcn_ id="table-name-good" placeholder="my_table" />
<p className="text-sm text-muted-foreground">Letters, numbers, and underscores only</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,38 @@
'use client'
import { Button } from 'ui'
export default function CopyLoadingStates() {
return (
<div className="flex flex-row gap-16">
<div className="flex flex-col gap-4">
<span className="text-xs text-foreground-muted">Bad Example</span>
<div className="flex flex-col gap-4">
<Button type="primary" loading>
Please wait...
</Button>
<Button type="primary" loading>
Loading...
</Button>
<Button type="danger" loading>
Processing...
</Button>
</div>
</div>
<div className="flex flex-col gap-4">
<span className="text-xs text-foreground-muted">Good Example</span>
<div className="flex flex-col gap-4">
<Button type="primary" loading>
Creating table...
</Button>
<Button type="primary" loading>
Loading schema...
</Button>
<Button type="danger" loading>
Deleting project...
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,24 @@
'use client'
import { CheckCircle } from 'lucide-react'
export default function CopySuccessMessages() {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4 w-[400px]">
<span className="text-xs text-foreground-muted">Bad Example</span>
<div className="flex flex-row items-center gap-2 border border-brand-500 rounded-md px-4 py-2 bg-brand-300 text-brand-600 shadow-md">
<CheckCircle size={16} strokeWidth={1.5} />
<p className="text-sm">Success!</p>
</div>
</div>
<div className="flex flex-col gap-4 w-[400px]">
<span className="text-xs text-foreground-muted">Good Example</span>
<div className="flex flex-row items-center gap-2 border border-brand-500 rounded-md px-4 py-2 bg-brand-300 text-brand-600 shadow-md">
<CheckCircle size={16} strokeWidth={1.5} />
<p className="text-sm">Table created successfully</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,51 @@
'use client'
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui'
import { Info } from 'lucide-react'
export default function CopyTooltips() {
return (
<div className="flex flex-row gap-20">
<div className="flex flex-col gap-4">
<span className="text-xs text-foreground-muted">Bad Example</span>
<div className="flex flex-row gap-8 items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
className="flex items-center px-1.5"
icon={<Info size={20} strokeWidth={1.5} />}
aria-label="More info"
/>
</TooltipTrigger>
<TooltipContent>
<p>This is a toggle switch</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="flex flex-col gap-4">
<span className="text-xs text-foreground-muted">Good Example</span>
<div className="flex flex-row gap-8 items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
className="flex items-center px-1.5"
icon={<Info size={20} strokeWidth={1.5} />}
aria-label="More info"
/>
</TooltipTrigger>
<TooltipContent>
<p>Restricts access based on user policies</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
)
}
@@ -27,7 +27,6 @@ import {
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Separator,
Switch,
Textarea,
} from 'ui'
@@ -116,10 +115,10 @@ export default function FormPatternsPageLayout() {
</PageSectionMeta>
<PageSectionContent>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent className="pt-6 space-y-6">
{/* Text Input */}
{/* Text Input */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="name"
@@ -128,7 +127,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Text Input"
description="Single-line text entry for short values"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Enter text" />
@@ -136,10 +134,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Password Input */}
{/* Password Input */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="password"
@@ -148,7 +146,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Password Input"
description="Masked input for secure text entry"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} type="password" placeholder="Enter password" />
@@ -156,10 +153,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Copyable Input */}
{/* Copyable Input */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="apiKey"
@@ -168,7 +165,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Copyable Input"
description="Read-only input with copy-to-clipboard functionality"
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<FormControl_Shadcn_>
<Input
@@ -182,10 +178,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Number Input */}
{/* Number Input */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="maxConnections"
@@ -194,7 +190,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Number Input"
description="Numeric input with min/max validation"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_
@@ -208,10 +203,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Input with Units */}
{/* Input with Units */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="duration"
@@ -220,7 +215,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Input with Units"
description="Input with additional unit label"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<PrePostTab postTab="MB" className="w-full">
@@ -230,10 +224,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Textarea */}
{/* Textarea */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="description"
@@ -242,7 +236,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Textarea"
description="Multi-line text input for longer content"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Textarea
@@ -255,10 +248,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Icon Upload */}
{/* Icon Upload */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="description"
@@ -267,7 +260,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Icon upload"
description="For icons, avatars, or small images with preview"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<div className="flex gap-4 items-center">
@@ -322,10 +314,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* File Upload */}
{/* File Upload */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="description"
@@ -334,7 +326,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="File Upload"
description="Drag-and-drop or select files for upload"
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<FormControl_Shadcn_>
<div
@@ -416,10 +407,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Switch */}
{/* Switch */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="enableFeature"
@@ -435,15 +426,14 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Checkbox */}
{/* Checkbox */}
<CardContent>
<FormItemLayout
layout="flex-row-reverse"
label="Checkbox"
description="Boolean values or multiple selections"
className="[&>div]:md:w-1/2"
>
<div className="w-full flex flex-col gap-4">
<FormField_Shadcn_
@@ -511,10 +501,10 @@ export default function FormPatternsPageLayout() {
/>
</div>
</FormItemLayout>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Select */}
{/* Select */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="region"
@@ -523,7 +513,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Select (Dropdown)"
description="Single selection from a list of options"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
@@ -546,10 +535,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Multi-Select */}
{/* Multi-Select */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="schemas"
@@ -558,7 +547,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Multi-Select"
description="Multiple selection from a list"
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<MultiSelector
onValuesChange={field.onChange}
@@ -584,10 +572,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Radio Group */}
{/* Radio Group */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="queueType"
@@ -596,7 +584,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Radio Group"
description="Single selection from multiple options"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
@@ -615,10 +602,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Date Picker */}
{/* Date Picker */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="expiryDate"
@@ -627,7 +614,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Date Picker"
description="Date selection with calendar popover"
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Popover_Shadcn_>
@@ -653,10 +639,10 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Field Array */}
{/* Field Array */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="redirectUris"
@@ -665,7 +651,6 @@ export default function FormPatternsPageLayout() {
layout="flex-row-reverse"
label="Field Array"
description="Dynamic list for adding/removing items"
className="[&>div]:md:w-1/2"
>
<div className="space-y-2 w-full">
{fields.map((field, index) => (
@@ -704,17 +689,16 @@ export default function FormPatternsPageLayout() {
</FormItemLayout>
)}
/>
</CardContent>
<Separator className="bg-border -mx-6 w-[calc(100%+3rem)]" />
{/* Action Field */}
{/* Action Field */}
<CardContent>
<FormItemLayout
layout="flex-row-reverse"
label="Action Field"
description="Button or link for navigation or performable actions"
className="[&>div]:md:w-1/2"
>
<div className="flex gap-2 items-center">
<div className="flex gap-2 items-center justify-end">
<Button
type="default"
icon={<ExternalLink size={14} />}
-6
View File
@@ -19,12 +19,6 @@ export const fragments: Registry = [
files: ['/Dialogs/TextConfirmModal.tsx'],
optionalPath: '/Dialogs',
},
{
name: 'ConfirmDialog',
type: 'components:fragment',
files: ['/Dialogs/ConfirmDialog.tsx'],
optionalPath: '/Dialogs',
},
{
name: 'PageContainer',
type: 'components:fragment',
+2 -1
View File
@@ -2,5 +2,6 @@ import { Registry } from '@/registry/schema'
import { examples } from '@/registry//examples'
import { fragments } from '@/registry/fragments'
import { charts } from '@/registry/charts'
import { copyWriting } from '@/registry/copy-writing'
export const registry: Registry = [...fragments, ...examples, ...charts]
export const registry: Registry = [...fragments, ...examples, ...charts, ...copyWriting]
@@ -0,0 +1,78 @@
import { KJUR } from 'jsrsasign'
import { useState } from 'react'
import { Button, CodeBlock } from 'ui'
const JWT_HEADER = { alg: 'HS256', typ: 'JWT' }
const generateRandomString = (length: number) => {
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
const MAX = Math.floor(256 / CHARS.length) * CHARS.length - 1
const randomUInt8Array = new Uint8Array(1)
for (let i = 0; i < length; i++) {
let randomNumber: number
do {
crypto.getRandomValues(randomUInt8Array)
randomNumber = randomUInt8Array[0]
} while (randomNumber > MAX)
result += CHARS[randomNumber % CHARS.length]
}
return result
}
const generateKeys = () => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const fiveYears = new Date(now.getFullYear() + 5, now.getMonth(), now.getDate())
const iat = Math.floor(today.valueOf() / 1000)
const exp = Math.floor(fiveYears.valueOf() / 1000)
const anonToken = { role: 'anon', iss: 'supabase', iat, exp }
const serviceToken = { role: 'service_role', iss: 'supabase', iat, exp }
const secret = generateRandomString(40)
const anonKey = KJUR.jws.JWS.sign(null, JWT_HEADER, anonToken, secret)
const serviceRoleKey = KJUR.jws.JWS.sign(null, JWT_HEADER, serviceToken, secret)
return { secret, anonKey, serviceRoleKey }
}
export default function JwtGeneratorSimple() {
const [keys, setKeys] = useState(generateKeys)
const regenerate = () => {
setKeys(generateKeys())
}
return (
<div className="border rounded-lg p-4">
<div className="grid mb-8">
<label htmlFor="secret">JWT_SECRET</label>
<CodeBlock language="bash" className="relative font-mono">
{keys.secret}
</CodeBlock>
</div>
<div className="grid mb-8">
<label htmlFor="anon">ANON_KEY</label>
<CodeBlock language="bash" className="relative font-mono">
{keys.anonKey}
</CodeBlock>
</div>
<div className="grid mb-8">
<label htmlFor="service">SERVICE_ROLE_KEY</label>
<CodeBlock language="bash" className="relative font-mono">
{keys.serviceRoleKey}
</CodeBlock>
</div>
<Button type="primary" onClick={regenerate}>
Generate new
</Button>
</div>
)
}
+10 -1
View File
@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic'
import { Suspense } from 'react'
const DynamicJwtGenerator = dynamic(() => import('./JwtGenerator'), { ssr: false })
const DynamicJwtGeneratorSimple = dynamic(() => import('./JwtGeneratorSimple'), { ssr: false })
const JwtGenerator = () => {
return (
@@ -13,4 +14,12 @@ const JwtGenerator = () => {
)
}
export { JwtGenerator }
const JwtGeneratorSimple = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<DynamicJwtGeneratorSimple />
</Suspense>
)
}
export { JwtGenerator, JwtGeneratorSimple }
+2 -5
View File
@@ -53,14 +53,11 @@ const metricsStackOptions: MetricsStackOption[] = [
Supabase alongside your app telemetry.
</>
),
href: '/guides/telemetry/metrics/datadog',
href: 'https://docs.datadoghq.com/integrations/supabase/',
icon: <Datadog className="h-5 w-5" />,
iconColor: '#632CA6',
iconBg: 'rgba(99,44,166,0.1)',
badges: [
{ label: 'Supabase guide', variant: 'default' },
{ label: 'Community', variant: 'community' },
],
badges: [{ label: 'Community', variant: 'community' }],
},
{
title: 'Vendor-agnostic / BYO Prometheus',
@@ -3,6 +3,7 @@ import { Clock, Heart, Server, SquareStack, Telescope } from 'lucide-react'
import {
IconBranching,
IconGitHub,
IconGitBranch,
IconMenuApi,
IconMenuAuth,
IconMenuCli,
@@ -85,6 +86,8 @@ function getMenuIcon(menuKey: string, width: number = 16, height: number = 16, c
return <IconMenuCli width={width} height={height} className={className} />
case 'status':
return <IconMenuStatus width={width} height={height} className={className} />
case 'git-branch':
return <IconGitBranch width={width} height={height} className={className} />
case 'github':
return <IconGitHub width={width} height={height} className={className} />
case 'support':
@@ -556,6 +556,28 @@ export function IconBranching({ width = 16, height = 16, className }: HomeMenuIc
)
}
export function IconGitBranch({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
width={width}
height={height}
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.5 2.25L4.5 11.25M15.75 4.5A2.25 2.25 0 1 1 11.25 4.5A2.25 2.25 0 1 1 15.75 4.5ZM6.75 13.5A2.25 2.25 0 1 1 2.25 13.5A2.25 2.25 0 1 1 6.75 13.5ZM13.5 6.75A6.75 6.75 0 0 1 6.75 13.5"
stroke="currentColor"
strokeMiterlimit="10"
strokeLinejoin="bevel"
/>
</svg>
)
}
export function IconGitHub({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
@@ -140,11 +140,10 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
icon: 'dev-cli',
href: '/guides/local-development' as `/${string}`,
level: 'local_development',
enabled: localDevelopmentEnabled,
},
{
label: 'Deployment',
icon: 'deployment',
label: 'Deployment & Branching',
icon: 'git-branch',
href: '/guides/deployment' as `/${string}`,
level: 'deployment',
},
@@ -491,9 +490,13 @@ export const gettingstarted: NavMenuConstant = {
url: '/guides/getting-started/ai-prompts' as `/${string}`,
},
{
name: 'Model context protocol (MCP)',
name: 'Supabase MCP server',
url: '/guides/getting-started/mcp' as `/${string}`,
},
{
name: 'Deploy MCP servers',
url: '/guides/getting-started/byo-mcp' as `/${string}`,
},
],
},
],
@@ -2250,7 +2253,6 @@ export const ai: NavMenuConstant = {
export const local_development: NavMenuConstant = {
icon: 'dev-cli',
title: 'Local Dev / CLI',
enabled: localDevelopmentEnabled,
url: '/guides/local-development',
items: [
{ name: 'Overview', url: '/guides/local-development' },
@@ -2259,7 +2261,11 @@ export const local_development: NavMenuConstant = {
url: undefined,
items: [
{ name: 'Getting started', url: '/guides/local-development/cli/getting-started' },
{ name: 'Configuration', url: '/guides/local-development/cli/config' },
{
name: 'Configuration',
url: '/guides/local-development/cli/config',
enabled: localDevelopmentEnabled,
},
{ name: 'CLI commands', url: '/reference/cli' },
],
},
@@ -2283,16 +2289,19 @@ export const local_development: NavMenuConstant = {
{
name: 'Restoring downloaded backup',
url: '/guides/local-development/restoring-downloaded-backup' as `/${string}`,
enabled: localDevelopmentEnabled,
},
{
name: 'Customizing email templates',
url: '/guides/local-development/customizing-email-templates' as `/${string}`,
enabled: localDevelopmentEnabled,
},
],
},
{
name: 'Testing',
url: undefined,
enabled: localDevelopmentEnabled,
items: [
{ name: 'Getting started', url: '/guides/local-development/testing/overview' },
{
@@ -2757,7 +2766,7 @@ export const telemetry: NavMenuConstant = {
},
{
name: 'Datadog',
url: '/guides/telemetry/metrics/datadog' as `/${string}`,
url: 'https://docs.datadoghq.com/integrations/supabase/' as `/${string}`,
},
{
name: 'Vendor-agnostic setup',
@@ -2848,9 +2857,9 @@ export const self_hosting: NavMenuConstant = {
}
export const deployment: NavMenuConstant = {
title: 'Deployment',
title: 'Deployment & Branching',
url: '/guides/deployment',
icon: 'deployment',
icon: 'git-branch',
items: [
{ name: 'Overview', url: '/guides/deployment' },
{
+4 -2
View File
@@ -2,15 +2,17 @@
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
To do this, you need to get the Project URL and key. Get the URL from [the API settings section](/dashboard/project/_/settings/api) of a project and the key from the [the API Keys section of a project's Settings page](/dashboard/project/_/settings/api-keys/).
To do this, you need to get the Project URL and key from [the project **Connect** dialog](/dashboard/project/\_?showConnect=true&connectTab={{ .tab }}&framework={{ .framework }}).
<Admonition type="note" title="Changes to API keys">
Supabase is changing the way keys work to improve project security and developer experience. You can [read the full announcement](https://github.com/orgs/supabase/discussions/29260), but in the transition period, you can use both the current `anon` and `service_role` keys and the new publishable key with the form `sb_publishable_xxx` which will replace the older keys.
To get the key values, open [the API Keys section of a project's Settings page](/dashboard/project/_/settings/api-keys/) and do the following:
In most cases, you can get the correct key from [the Project's **Connect** dialog](/dashboard/project/\_?showConnect=true&connectTab={{ .tab }}&framework={{ .framework }}), but if you want a specific key, you can find all keys in [the API Keys section of a Project's Settings page](/dashboard/project/_/settings/api-keys/):
- **For legacy keys**, copy the `anon` key for client-side operations and the `service_role` key for server-side operations from the **Legacy API Keys** tab.
- **For new keys**, open the **API Keys** tab, if you don't have a publishable key already, click **Create new API Keys**, and copy the value from the **Publishable key** section.
</Admonition>
[Read the API keys docs](/docs/guides/api/api-keys) for a full explanation of all key types and their uses.
@@ -1,12 +1,16 @@
{/* TODO: How to completely consolidate partials? */}
You can also get the Project URL and key from [the project's **Connect** dialog](/dashboard/project/\_?showConnect=true&connectTab={{ .tab }}&framework={{ .framework }}).
<Admonition type="note" title="Changes to API keys">
Supabase is changing the way keys work to improve project security and developer experience. You can [read the full announcement](https://github.com/orgs/supabase/discussions/29260), but in the transition period, you can use both the current `anon` and `service_role` keys and the new publishable key with the form `sb_publishable_xxx` which will replace the older keys.
To get the key values, open [the API Keys section of a project's Settings page](/dashboard/project/_/settings/api-keys/) and do the following:
In most cases, you can get the correct key from [the Project's **Connect** dialog](/dashboard/project/\_?showConnect=true&connectTab={{ .tab }}&framework={{ .framework }}), but if you want a specific key, you can find all keys in [the API Keys section of a Project's Settings page](/dashboard/project/_/settings/api-keys/):
- **For legacy keys**, copy the `anon` key for client-side operations and the `service_role` key for server-side operations from the **Legacy API Keys** tab.
- **For new keys**, open the **API Keys** tab, if you don't have a publishable key already, click **Create new API Keys**, and copy the value from the **Publishable key** section.
</Admonition>
[Read the API keys docs](/docs/guides/api/api-keys) for a full explanation of all key types and their uses.
@@ -0,0 +1,31 @@
<Admonition type="caution">
The `pgrst.db_pre_request` configuration only works with the **Data API** (PostgREST). It does not work with Realtime, Storage, or other Supabase products.
If you're using `db_pre_request` to call a function (like `set_information()`) that sets up context or performs checks on every request, and you need similar behavior for other Supabase products, you must call the function directly in your Row Level Security (RLS) policies instead.
**Example:**
If you have a `db_pre_request` function that calls `set_information()` that returns `true` to set up context or perform checks, and you have an RLS policy like:
```sql
create policy "Individuals can view their own todos."
on todos for select
using ( (select auth.uid()) = user_id );
```
To achieve the same behavior with other Supabase products, you need to call the function directly in your RLS policy:
```sql
create policy "Individuals can view their own todos."
on todos for select
using ( set_information() AND (select auth.uid()) = user_id );
```
This ensures the function is called when evaluating RLS policies for all products, not just Data API requests.
**Performance consideration:**
Be aware that calling functions directly in RLS policies can impact database performance, as the function is evaluated for each row when the policy is checked. Consider optimizing your function or using caching strategies if performance becomes an issue.
</Admonition>
@@ -33,7 +33,8 @@ Now we are going to set up the database schema. You can just copy/paste the SQL
</TabPanel>
</Tabs>
<$Partial path="api_settings.mdx" />
<$Partial path="api_settings.mdx" variables={{ "framework": "{{ .framework }}", "tab": "{{ .tab }}" }}
/>
### Set up Google authentication
@@ -23,7 +23,7 @@ className="rounded-lg border border-foreground/10 bg-surface-100 text-foreground
3. Authenticate with HTTP Basic Auth:
- **Username**: `service_role`
- **Password**: a service role secret (JWT) from [**Project Settings > JWT** (opens in a new tab)](/dashboard/project/_/settings/jwt) or any other Secret API key from [**Project Settings > API keys** (opens in a new tab)](/dashboard/project/_/settings/api-keys)
- **Password**: a service role secret (JWT) from [**Project Settings > JWT**](/dashboard/project/_/settings/jwt) or any other Secret API key from [**Project Settings > API keys** (opens in a new tab)](/dashboard/project/_/settings/api-keys)
Testing locally is as simple as running `curl` with your service role secret:
@@ -34,7 +34,7 @@ className="rounded-lg border border-foreground/10 bg-surface-100 text-foreground
You can provision long-lived automation tokens in two ways:
- Create an account access token once at [**Account Settings > Access Tokens (opens in a new tab)**](/dashboard/account/tokens) and reuse it wherever you configure observability tooling.
- Create an account access token once at [**Account Settings > Access Tokens**](/dashboard/account/tokens) and reuse it wherever you configure observability tooling.
- **Optional**: programmatically exchange an access token for project API keys via the [Management API ](/docs/reference/api/management-projects-api-keys-retrieve').
```bash
@@ -55,4 +55,5 @@ supabase migration new user_management_starter
</TabPanel>
</Tabs>
<$Partial path="api_settings.mdx" />
<$Partial path="api_settings.mdx" variables={{ "framework": "{{ .framework }}", "tab": "{{ .tab }}" }}
/>
@@ -0,0 +1,44 @@
### Universal links
For a better user experience, you can use **Universal Links** instead of custom URL schemes. Universal Links allow your app to open directly from web links without showing a browser redirect prompt.
To enable Universal Links, you need to:
1. **Configure Associated Domains** in your Xcode project:
- Add your domain to the Associated Domains capability
- Format: `applinks:yourdomain.com`
2. **Host the Apple App Site Association (AASA) file** on your own infrastructure:
- The file must be accessible at `https://yourdomain.com/.well-known/apple-app-site-association` or `https://yourdomain.com/apple-app-site-association`
- The file must be served with `Content-Type: application/json` (or `text/json`)
- The file must be accessible over HTTPS without redirects
- The file should not have a file extension
<Admonition type="caution">
Supabase does not currently support hosting the AASA file. You must host this file on your own
infrastructure following [Apple's best
practices](https://developer.apple.com/documentation/xcode/supporting-associated-domains).
</Admonition>
The AASA file format should look like this:
```json
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.BUNDLE_ID",
"paths": ["*"]
}
]
}
}
```
Replace `TEAM_ID` with your Apple Developer Team ID and `BUNDLE_ID` with your app's bundle identifier.
For detailed setup instructions, see [Apple's documentation on supporting associated domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains).
+15
View File
@@ -32,6 +32,21 @@ There are 4 types of API keys that can be used with Supabase:
</Admonition>
<Admonition type="note" title="Changes to API keys">
Supabase is changing the way keys work to improve project security and developer experience. You can [read the full announcement](https://github.com/orgs/supabase/discussions/29260), but in the transition period, you can use both the current `anon` and `service_role` keys and the new publishable key with the form `sb_publishable_xxx` which will replace the older keys.
</Admonition>
## Where to find keys
You can find API keys in a couple of different places.
In most cases, you can get the correct key from [the Project's **Connect** dialog](/dashboard/project/_?showConnect=true), but if you want a specific key, you can find all keys in [the API Keys section of a Project's Settings page](/dashboard/project/_/settings/api-keys/):
- **For legacy keys**, copy the `anon` key for client-side operations and the `service_role` key for server-side operations from the **Legacy API Keys** tab.
- **For new keys**, open the **API Keys** tab, if you don't have a publishable key already, click **Create new API Keys**, and copy the value from the **Publishable key** section.
## `anon` and publishable keys
The `anon` and publishable keys secure the public components of your application. Public components run in environments where it is impossible to secure any secrets. These include:
@@ -52,20 +52,26 @@ create table
Every Supabase project has a unique API URL. Your API is secured behind an API gateway which requires an API Key for every request.
1. Go to the [Settings](/dashboard/project/_/settings/general) page in the Dashboard.
2. Click **API** in the sidebar.
3. Find your API `URL`, `anon`, and `service_role` keys on this page.
{/* TODO: Further consolidate partial */}
<video width="99%" muted playsInline controls={true}>
<source
src="https://xguihxuzqibwxjnimxev.supabase.co/storage/v1/object/public/videos/docs/api/api-url-and-key.mp4"
type="video/mp4"
/>
</video>
To do this, you need to get the Project URL and key from [the project's **Connect** dialog](/dashboard/project/_?showConnect=true).
<Admonition type="note" title="Changes to API keys">
Supabase is changing the way keys work to improve project security and developer experience. You can [read the full announcement](https://github.com/orgs/supabase/discussions/29260), but in the transition period, you can use both the current `anon` and `service_role` keys and the new publishable key with the form `sb_publishable_xxx` which will replace the older keys.
In most cases, you can get the correct key from [the Project's **Connect** dialog](/dashboard/project/_?showConnect=true), but if you want a specific key, you can find all keys in [the API Keys section of a Project's Settings page](/dashboard/project/_/settings/api-keys/):
- **For legacy keys**, copy the `anon` key for client-side operations and the `service_role` key for server-side operations from the **Legacy API Keys** tab.
- **For new keys**, open the **API Keys** tab, if you don't have a publishable key already, click **Create new API Keys**, and copy the value from the **Publishable key** section.
</Admonition>
[Read the API keys docs](/docs/guides/api/api-keys) for a full explanation of all key types and their uses.
The REST API is accessible through the URL `https://<project_ref>.supabase.co/rest/v1`
Both of these routes require the `anon` key to be passed through an `apikey` header.
Both of these routes require the key to be passed through an `apikey` header.
## Using the API
@@ -97,6 +97,8 @@ This configures the `public.check_request` function to run on every Data API req
notify pgrst, 'reload config';
```
<$Partial path="db_pre_request_warning.mdx" />
Inside the function you can perform any additional checks on the request headers or JWT and raise an exception to prevent the request from completing. For example, this exception raises a HTTP 402 Payment Required response with a `hint` and additional `X-Powered-By` header:
```sql
@@ -249,6 +251,8 @@ alter role authenticator
notify pgrst, 'reload config';
```
<$Partial path="db_pre_request_warning.mdx" />
To clear old entries in the `private.rate_limits` table, set up a [pg_cron](/docs/guides/database/extensions/pg_cron) job to clean them up.
</TabPanel>
@@ -329,6 +333,8 @@ alter role authenticator
notify pgrst, 'reload config';
```
<$Partial path="db_pre_request_warning.mdx" />
</TabPanel>
</Tabs>
@@ -191,6 +191,8 @@ With Deep Linking, you can configure this redirect to open a specific page. This
For more info: https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app
<$Partial path="universal_links_apple.mdx" />
</TabPanel>
<TabPanel id="windows" label="Windows">
@@ -343,6 +345,8 @@ With Deep Linking, you can configure this redirect to open a specific page. This
</plist>
```
<$Partial path="universal_links_apple.mdx" />
</TabPanel>
</$Show>
<$Show if="sdk:kotlin">
@@ -494,7 +494,71 @@ Store the client secret securely. It will only be shown once. If you lose it, yo
</TabPanel>
<TabPanel id="programmatically" label="Programmatically">
You can register clients programmatically by calling your project's auth server admin endpoint with a secret key:
You can register clients programmatically using the SDK admin endpoints or by calling your project's auth server admin endpoint directly.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://your-project.supabase.co',
'your-service-role-key' // Use service_role key for admin operations
)
// Create an OAuth client
const { data, error } = await supabase.auth.admin.oauth.createClient({
name: 'My Third-Party App',
redirect_uris: ['https://my-app.com/auth/callback', 'https://my-app.com/auth/silent-callback'],
client_type: 'confidential',
})
if (error) {
console.error('Error creating client:', error)
} else {
console.log('Client created:', data)
console.log('Client ID:', data.client_id)
console.log('Client Secret:', data.client_secret) // Store this securely!
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from supabase import create_client
supabase = create_client(
'https://your-project.supabase.co',
'your-service-role-key' # Use service_role key for admin operations
)
# Create an OAuth client
response = supabase.auth.admin.oauth.create_client({
'name': 'My Third-Party App',
'redirect_uris': [
'https://my-app.com/auth/callback',
'https://my-app.com/auth/silent-callback'
],
'client_type': 'confidential'
})
print('Client created:', response)
print('Client ID:', response.client_id)
print('Client Secret:', response.client_secret) # Store this securely!
```
</TabPanel>
</$Show>
<TabPanel id="curl" label="cURL">
**Production:**
@@ -538,9 +602,12 @@ Response:
}
```
</TabPanel>
</Tabs>
<Admonition type="note">
For complete API documentation, see the [OAuth Admin API reference](/todo).
For complete API documentation, see the [OAuth Admin API reference](/docs/reference/javascript/auth-admin-oauth-admin).
</Admonition>
@@ -548,6 +615,39 @@ For complete API documentation, see the [OAuth Admin API reference](/todo).
To view all registered OAuth clients:
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
const { data, error } = await supabase.auth.admin.oauth.listClients()
if (error) {
console.error('Error listing clients:', error)
} else {
console.log('OAuth clients:', data)
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
clients = supabase.auth.admin.oauth.list_clients()
print('OAuth clients:', clients)
```
</TabPanel>
</$Show>
<TabPanel id="curl" label="cURL">
**Production:**
```bash
@@ -565,6 +665,9 @@ curl 'http://localhost:54321/auth/v1/admin/oauth/clients' \
</TabPanel>
</Tabs>
</TabPanel>
</Tabs>
## Customizing tokens (optional)
By default, OAuth access tokens include standard claims like `user_id`, `role`, and `client_id`. If you need to customize tokens—for example, to set a specific `audience` claim for third-party validation or add client-specific metadata—use [Custom Access Token Hooks](/docs/guides/auth/auth-hooks/access-token-hook).
@@ -79,7 +79,7 @@ When building your own MCP server, integrate with Supabase Auth to authenticate
**Looking for an easier way to build MCP servers?**
[FastMCP](https://gofastmcp.com) provides a streamlined way to build MCP servers with built-in Supabase Auth integration. FastMCP handles OAuth configuration, token management, and authentication flows automatically, letting you focus on building your AI agent's functionality. Check out their [Supabase integration guide](https://gofastmcp.com/todo) to get started quickly.
[FastMCP](https://gofastmcp.com) provides a streamlined way to build MCP servers with built-in Supabase Auth integration. FastMCP handles OAuth configuration, token management, and authentication flows automatically, letting you focus on building your AI agent's functionality. Check out their [Supabase integration guide](https://gofastmcp.com/integrations/supabase) to get started quickly.
</Admonition>
@@ -52,9 +52,11 @@ hideToc: true
<StepHikeCompact.Step step={3}>
<StepHikeCompact.Details title="Declare Supabase Environment Variables">
Rename `.env.example` to `.env.local` and populate with [your project's URL and Key](/dashboard/project/_/settings/api).
Rename `.env.example` to `.env.local` and populate with your Supabase connection variables:
<$Partial path="api_settings_steps.mdx" />
<ProjectConfigVariables variable="url" />
<ProjectConfigVariables variable="publishable" />
<ProjectConfigVariables variable="anon" />
</StepHikeCompact.Details>
@@ -65,6 +67,8 @@ hideToc: true
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_... or anon key
```
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} />
</StepHikeCompact.Code>
</StepHikeCompact.Step>
@@ -66,7 +66,9 @@ hideToc: true
Create a helper file `lib/supabase.ts` that exports a Supabase client using your Project URL and key.
<$Partial path="api_settings_steps.mdx" />
<ProjectConfigVariables variable="url" />
<ProjectConfigVariables variable="publishable" />
<ProjectConfigVariables variable="anon" />
</StepHikeCompact.Details>
<StepHikeCompact.Code>
@@ -106,6 +108,7 @@ hideToc: true
})
}
```
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "exporeactnative", "tab": "mobiles" }} />
</StepHikeCompact.Code>
@@ -63,9 +63,12 @@ hideToc: true
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Declare Supabase Environment Variables">
Create `.env.local` and populate with your project's URL and Key.
Rename `.env.example` to `.env.local` and populate with your Supabase connection variables:
<ProjectConfigVariables variable="url" />
<ProjectConfigVariables variable="publishable" />
<ProjectConfigVariables variable="anon" />
<$Partial path="api_settings_steps.mdx" />
</StepHikeCompact.Details>
@@ -75,6 +78,7 @@ hideToc: true
VITE_SUPABASE_URL=your-project-url
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=sb_publishable_... or anon key
```
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "react", "tab": "frameworks" }} />
</StepHikeCompact.Code>
@@ -16,7 +16,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="project_setup.mdx" />
<$Partial path="project_setup.mdx" variables={{ "framework": "exporeactnative", "tab": "mobiles" }} />
## Building the app
@@ -832,7 +832,7 @@ For more information, follow the [Supabase Login with Apple](/docs/guides/auth/s
</TabPanel>
<TabPanel id="react-apple-signin-auth" label="Web">
#### Prerequisites
Before proceeding, as per the mobile options you need an Apple Service ID. To obtain it you can follow the [Invertase Initial Setup Guide](https://github.com/invertase/react-native-apple-authentication/blob/main/docs/INITIAL_SETUP.md) and the [Invertase Android Setup Guide](https://github.com/invertase/react-native-apple-authentication/blob/main/docs/ANDROID_EXTRA.md) mentioned in the Invertase tab.
@@ -45,6 +45,8 @@ Create a `.env.local` file in the project root directory. In the file, set the p
<ProjectConfigVariables variable="publishable" />
<ProjectConfigVariables variable="anon" />
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} />
<Tabs scrollable size="small" type="underlined" defaultActiveId="nextjs" queryGroup="framework">
<TabPanel id="nextjs" label="Next.js">
@@ -88,6 +88,8 @@ alter role authenticator set pgrst.db_pre_request to 'filter_plan_requests';
notify pgrst, 'reload config';
```
<$Partial path="db_pre_request_warning.mdx" />
Replace `'123.123.123.123'` with your actual IP address.
## Disabling explain
+3 -3
View File
@@ -1,8 +1,8 @@
---
title: Deployment
title: Deployment & Branching
---
Deploying your app makes it live and accessible to users. Usually, you will deploy an app to at least two environments: a production environment for users and (one or multiple) staging or preview environments for developers.
Deploying your app makes it live and accessible to users. Usually, you deploy an app to at least two environments: a production environment for users and (one or multiple) staging or preview environments for developers.
Supabase provides several options for environment management and deployment.
@@ -16,7 +16,7 @@ You can maintain separate development, staging, and production environments for
<Admonition type="tip" title="Self-hosting">
See the [self-hosting guides](/docs/guides/self-hosting) for instructions on hosting your own Supabase stack.
Read the [self-hosting guides](/docs/guides/self-hosting) for instructions on hosting your own Supabase stack.
</Admonition>
@@ -4,7 +4,7 @@ title: 'Production Checklist'
description: 'Things to do before making your app publicly available'
---
After developing your project and deciding it's Production Ready, you should run through this checklist to ensure that your project:
After developing your project and deciding it's production ready, you should run through this checklist to ensure that your project:
- is secure
- won't falter under the expected load
@@ -13,7 +13,7 @@ After developing your project and deciding it's Production Ready, you should run
## Security
- Ensure RLS is enabled
- Tables that do not have RLS enabled with reasonable policies allow any client to access and modify their data. This is unlikely to be what you want in the majority of cases.
- Tables that do not have RLS enabled with reasonable policies allow any client to access and modify their data. This is usually not what you want.
- [Learn more about RLS](/docs/guides/database/postgres/row-level-security).
- Enable replication on tables containing sensitive data by enabling Row Level Security (RLS) and setting row security policies:
- Go to the Authentication > Policies page in the Supabase Dashboard to enable RLS and create security policies.
@@ -21,8 +21,8 @@ After developing your project and deciding it's Production Ready, you should run
- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) (see: [dashboard](/dashboard/project/_/auth/policies))
- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for your database (see: [dashboard](/dashboard/project/_/database/settings#network-restrictions)).
- Ensure that your Supabase Account is protected with multi-factor authentication (MFA).
- If using a GitHub signin, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app.
- If using email+password signin, set up [MFA for your Supabase account](/docs/guides/platform/multi-factor-authentication#enable-mfa).
- If using a GitHub sign-in, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app.
- If using email+password sign-in, set up [MFA for your Supabase account](/docs/guides/platform/multi-factor-authentication#enable-mfa).
- Enable [MFA enforcement on your organization](/docs/guides/platform/network-restrictions). This ensures all users must have a valid MFA backed session to interact with organization and project resources.
- Consider [adding multiple owners on your Supabase org](/dashboard/org/_/team). This ensures that if one of the owners is unreachable or loses access to their account, you still have Owner access to your org.
- Ensure email confirmations are [enabled](/dashboard/project/_/auth/providers) in the `Settings > Auth` page.
@@ -30,7 +30,7 @@ After developing your project and deciding it's Production Ready, you should run
- Increase the length of the OTP if you need a higher level of entropy.
- If your application requires a higher level of security, consider setting up [multi-factor authentication](/docs/guides/auth/auth-mfa) (MFA) for your users.
- Use a custom SMTP server for auth emails so that your users can see that the mails are coming from a trusted domain (preferably the same domain that your app is hosted on). Grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc.
- Think hard about how _you_ would abuse your service as an attacker, and mitigate.
- Consider how _you_ might abuse your service as an attacker, and take steps to mitigate it.
- Review these [common cybersecurity threats](https://auth0.com/docs/security/prevent-threats).
- Check and review issues in your database using [Security Advisor](/dashboard/project/_/database/security-advisor).
@@ -54,10 +54,10 @@ After developing your project and deciding it's Production Ready, you should run
- Applications on the Free Plan that exhibit extremely low activity in a 7 day period may be paused by Supabase to save on server resources.
- You can restore paused projects from the Supabase dashboard.
- Upgrade to Pro to guarantee that your project will not be paused for inactivity.
- Database backups are not available for download on the Free Plan.
- You can set up your own backup systems using tools like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) or [wal-g](https://github.com/wal-g/wal-g).
- Database backups are not available for download for Free Plan projects.
- You can set up your own backup systems using tools like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump) or [wal-g](https://github.com/wal-g/wal-g).
- Nightly backups for Pro Plan projects are available on the Supabase dashboard for up to 7 days.
- Point in Time Recovery (PITR) allows a project to be backed up at much shorter intervals. This provides users an option to restore to any chosen point of up to seconds in granularity. In terms of Recovery Point Objective (RPO), Daily Backups would be suitable for projects willing to lose up to 24 hours worth of data. If a lower RPO is required, enable PITR.
- Point-in-Time Recovery (PITR) allows a project to be backed up at much shorter intervals. This provides users an option to restore to any chosen point of up to seconds in granularity. If a lower RPO is required, enable PITR.
- Supabase Projects use disks that offer 99.8-99.9% durability by default.
- Use Read Replicas if you require availability resilience to a disk failure event
- Use PITR if you require durability resilience to a disk failure event
@@ -0,0 +1,212 @@
---
id: 'ai-tools-byo-mcp'
title: 'Deploy MCP servers'
description: 'Build and deploy remote MCP servers on Supabase Edge Functions'
---
Build and deploy [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25) (MCP) servers on Supabase using [Edge Functions](/docs/guides/functions).
<Admonition type="note">
This guide covers MCP servers that do not require authentication. Auth support for MCP on Edge Functions is coming soon.
</Admonition>
## Deploy your MCP server
### Prerequisites
Before you begin, make sure you have:
- [Docker](https://docs.docker.com/get-docker/) installed (required for local Supabase development)
- [Deno](https://deno.land/) installed (Supabase Edge Functions runtime)
- [Supabase CLI](/docs/guides/cli/getting-started) installed
### Create a new project
Start by creating a new Supabase project:
```bash
mkdir my-mcp-server
cd my-mcp-server
supabase init
```
### Create the MCP server function
Create a new Edge Function for your MCP server:
```bash
supabase functions new mcp
```
Create a `deno.json` file in `supabase/functions/mcp/` with the required dependencies:
```json
{
"imports": {
"@hono/mcp": "npm:@hono/mcp@^0.1.1",
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.24.3",
"hono": "npm:hono@^4.9.2",
"zod": "npm:zod@^4.1.13"
}
}
```
<Admonition type="tip">
This tutorial uses the [official MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk), but you can use any MCP framework that's compatible with the [Edge Runtime](/docs/guides/functions), such as [mcp-lite](https://github.com/fiberplane/mcp-lite), [mcp-use](https://github.com/mcp-use/mcp-use), or [mcp-handler](https://github.com/vercel/mcp-handler).
</Admonition>
Replace the contents of `supabase/functions/mcp/index.ts` with:
```ts
// Setup type definitions for built-in Supabase Runtime APIs
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPTransport } from '@hono/mcp'
import { Hono } from 'hono'
import { z } from 'zod'
// Create Hono app
const app = new Hono()
// Create your MCP server
const server = new McpServer({
name: 'mcp',
version: '0.1.0',
})
// Register a simple addition tool
server.registerTool(
'add',
{
title: 'Addition Tool',
description: 'Add two numbers together',
inputSchema: { a: z.number(), b: z.number() },
},
({ a, b }) => ({
content: [{ type: 'text', text: String(a + b) }],
})
)
// Handle MCP requests at the root path
app.all('/', async (c) => {
const transport = new StreamableHTTPTransport()
await server.connect(transport)
return transport.handleRequest(c)
})
Deno.serve(app.fetch)
```
### Local development
Start the Supabase local development stack:
```bash
supabase start
```
In a separate terminal, serve your function:
```bash
supabase functions serve --no-verify-jwt mcp
```
Your MCP server is now running at:
```
http://localhost:54321/functions/v1/mcp
```
<Admonition type="note">
The `--no-verify-jwt` flag disables JWT verification at the Edge Function layer so your MCP server can accept unauthenticated requests. Authenticated MCP support is coming soon.
</Admonition>
### Test your MCP server
Test your server with the official [MCP Inspector](https://github.com/modelcontextprotocol/inspector):
```bash
npx -y @modelcontextprotocol/inspector
```
Use the local endpoint `http://localhost:54321/functions/v1/mcp` in the inspector UI to explore available tools and test them interactively.
### Deploy to production
When you're ready to deploy, link your project and deploy the function:
```bash
supabase link --project-ref <your-project-ref>
supabase functions deploy --no-verify-jwt mcp
```
Your MCP server will be available at:
```
https://<your-project-ref>.supabase.co/functions/v1/mcp
```
Update your MCP client configuration to use the production URL.
## Adding more tools
Extend your MCP server by registering additional tools. Here's an example that queries your Supabase database:
```ts
import { createClient } from 'jsr:@supabase/supabase-js@2'
// Create Supabase client
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
server.registerTool(
'list_users',
{
title: 'List Users',
description: 'Get a list of users from the database',
inputSchema: { limit: z.number().optional().default(10) },
},
async ({ limit }) => {
const { data, error } = await supabase
.from('users')
.select('id, email, created_at')
.limit(limit)
if (error) {
return {
content: [{ type: 'text', text: `Error: ${error.message}` }],
isError: true,
}
}
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
}
}
)
```
## Examples
You can find ready-to-use MCP server implementations here:
- [MCP server examples on GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/mcp/)
## Resources
- [Model Context Protocol Specification](https://modelcontextprotocol.io/specification/2025-11-25)
- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- [Supabase Edge Functions](/docs/guides/functions)
- [OAuth 2.1 Server](/docs/guides/auth/oauth-server)
- [MCP Authentication](/docs/guides/auth/oauth-server/mcp-authentication)
- [MCP server examples on GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/mcp)
- [Building MCP servers with mcp-lite](/docs/guides/functions/examples/mcp-server-mcp-lite) - Alternative lightweight framework
@@ -79,6 +79,8 @@ hideToc: true
}
```
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "flutter", "tab": "mobiles" }} />
</StepHikeCompact.Code>
</StepHikeCompact.Step>
@@ -61,6 +61,9 @@ hideToc: true
cp .env.example .env
```
{/* TODO: Not ideal for frameworks that have no entry in Connect */}
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "", "tab": "frameworks" }} />
</StepHikeCompact.Code>
</StepHikeCompact.Step>
@@ -58,6 +58,8 @@ hideToc: true
)
```
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "swift", "tab": "mobiles" }} />
</StepHikeCompact.Code>
</StepHikeCompact.Step>
@@ -98,6 +98,8 @@ hideToc: true
...
```
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "androidkotlin", "tab": "mobiles" }} />
</StepHikeCompact.Code>
</StepHikeCompact.Step>
@@ -58,6 +58,8 @@ hideToc: true
</$CodeTabs>
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} />
</StepHikeCompact.Code>
</StepHikeCompact.Step>
@@ -84,6 +84,7 @@ hideToc: true
```
</$CodeTabs>
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "nuxt", "tab": "frameworks" }} />
</StepHikeCompact.Code>
@@ -74,6 +74,8 @@ hideToc: true
</$CodeTabs>
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "react", "tab": "frameworks" }} />
</StepHikeCompact.Code>
</StepHikeCompact.Step>
@@ -100,6 +100,7 @@ hideToc: true
},
});
```
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "refine", "tab": "frameworks" }} />
</StepHikeCompact.Code>
@@ -72,6 +72,8 @@ hideToc: true
</$CodeTabs>
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "solidjs", "tab": "frameworks" }} />
</StepHikeCompact.Code>
</StepHikeCompact.Step>
@@ -72,6 +72,7 @@ hideToc: true
```
</$CodeTabs>
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "sveltekit", "tab": "frameworks" }} />
</StepHikeCompact.Code>
@@ -73,6 +73,7 @@ hideToc: true
```
</$CodeTabs>
<$Partial path="api_settings_steps.mdx" variables={{ "framework": "vue", "tab": "frameworks" }} />
</StepHikeCompact.Code>
@@ -13,7 +13,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="project_setup.mdx" />
<$Partial path="project_setup.mdx" variables={{ "framework": "ionicangular", "tab": "mobiles" }} />
## Building the app
@@ -14,7 +14,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="project_setup.mdx" />
<$Partial path="project_setup.mdx" variables={{ "framework": "exporeactnative", "tab": "mobiles" }} />
## Building the app
@@ -14,7 +14,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="project_setup.mdx" />
<$Partial path="project_setup.mdx" variables={{ "framework": "flutter", "tab": "mobiles" }} />
## Building the app
@@ -13,7 +13,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="project_setup.mdx" />
<$Partial path="project_setup.mdx" variables={{ "framework": "ionicangular", "tab": "mobiles" }} />
## Building the app
@@ -13,7 +13,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="project_setup.mdx" />
<$Partial path="project_setup.mdx" variables={{ "framework": "ionicreact", "tab": "mobiles" }} />
## Building the app
@@ -13,7 +13,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="project_setup.mdx" />
<$Partial path="project_setup.mdx" variables={{ "framework": "vuejs", "tab": "frameworks" }} />
## Building the app
@@ -17,7 +17,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="kotlin_project_setup.mdx" />
<$Partial path="kotlin_project_setup.mdx" variables={{ "framework": "androidkotlin", "tab": "mobiles" }} />
## Building the app
@@ -14,7 +14,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="project_setup.mdx" />
<$Partial path="project_setup.mdx" variables={{ "framework": "nextjs", "tab": "frameworks" }} />
## Building the app
@@ -14,7 +14,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="project_setup.mdx" />
<$Partial path="project_setup.mdx" variables={{ "framework": "nuxt", "tab": "frameworks" }} />
## Building the app
@@ -14,7 +14,7 @@ If you get stuck while working through this guide, refer to the [full example on
</Admonition>
<$Partial path="project_setup.mdx" variables={{ "framework": "React" }} />
<$Partial path="project_setup.mdx" variables={{ "framework": "react", "tab": "frameworks" }} />
## Building the app
Loaded 100 of 492 files, more files were not shown because too many files have changed in this diff. Show more