Landing page agent focus (#47989)

This commit is contained in:
Saxon Fletcher authored and GitHub committed 2026-07-21 14:30:36 +10:00
1 parent e3d7267845
commit 2d745edfb4
13 files changed
+785 -194

No files matched your search

+68 -37
View File
@@ -5,6 +5,7 @@ import { cn } from 'ui'
import { IconPanel } from 'ui-patterns/IconPanel'
import { TextLink } from 'ui-patterns/TextLink'
import { FrameworkQuickstarts } from '@/components/HomePageCover'
import { MIGRATION_PAGES } from '@/components/Navigation/NavigationMenu/NavigationMenu.constants'
import { GlassPanelWithIconPicker } from '@/features/ui/GlassPanelWithIconPicker'
import { IconPanelWithIconPicker } from '@/features/ui/IconPanelWithIconPicker'
@@ -45,7 +46,7 @@ const products = [
href: '/guides/database/overview',
description:
'Supabase provides a full Postgres database for every project with Realtime functionality, database backups, extensions, and more.',
span: 'col-span-12 md:col-span-6',
span: 'col-span-12',
},
{
title: 'Auth',
@@ -226,26 +227,55 @@ const additionalResources = [
const HomePage = () => (
<HomeLayout>
<div className="flex flex-col">
<h2 id="products">Products</h2>
<ul className="grid grid-cols-12 gap-6 not-prose [&_svg]:text-brand-600">
{products.map((product) => {
return (
<li key={product.title} className={cn(product.span ?? 'col-span-12 md:col-span-4')}>
<Link href={product.href} passHref>
<GlassPanelWithIconPicker {...product}>
{product.description}
</GlassPanelWithIconPicker>
</Link>
</li>
)
})}
</ul>
{isFeatureEnabled('docs:full_getting_started') && (
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0">
<h2 id="connect-a-framework" className="group scroll-mt-24">
Connect a framework
</h2>
<p className="m-0 p-0 text-sm text-foreground-light">
Start with a framework quickstart and connect your project in minutes.
</p>
</div>
<div className="flex flex-col lg:grid grid-cols-12 gap-6 py-12 border-b">
<div className="col-span-4">
<h2 id="postgres-integrations" className="scroll-mt-24 m-0">
Modules
<div className="col-span-8 not-prose">
<FrameworkQuickstarts />
</div>
</div>
)}
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0">
<h2 id="products" className="group scroll-mt-24">
Build your backend
</h2>
<p className="m-0 p-0 text-sm text-foreground-light">
Build with a complete backend platform, from your database to your application logic.
</p>
</div>
<ul className="col-span-8 grid grid-cols-12 gap-6 not-prose [&_svg]:text-brand-600">
{products.map((product) => {
return (
<li key={product.title} className={cn(product.span ?? 'col-span-12 md:col-span-6')}>
<Link href={product.href} passHref>
<GlassPanelWithIconPicker {...product}>
{product.description}
</GlassPanelWithIconPicker>
</Link>
</li>
)
})}
</ul>
</div>
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0">
<h2 id="postgres-integrations" className="scroll-mt-24">
Extend your database
</h2>
<p className="m-0 p-0 text-sm text-foreground-light">
Extend your database with built-in tools for AI, APIs, scheduled jobs, and queues.
</p>
</div>
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
{postgresIntegrations.map((integration) => (
@@ -261,15 +291,14 @@ const HomePage = () => (
</div>
</div>
<div className="flex flex-col lg:grid grid-cols-12 gap-6 py-12 border-b">
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0 [&_h3]:m-0">
<div className="md:max-w-xs 2xl:max-w-none">
<div className="flex items-center gap-3 mb-3 text-brand-600">
<h2 id="client-libraries" className="group scroll-mt-24">
Client Libraries
</h2>
</div>
</div>
<h2 id="client-libraries" className="group scroll-mt-24">
Use a client library
</h2>
<p className="m-0 p-0 text-sm text-foreground-light">
Use Supabase from the language and framework your application is built with.
</p>
</div>
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
@@ -291,7 +320,7 @@ const HomePage = () => (
</div>
</div>
{isFeatureEnabled('docs:full_getting_started') && (
<div className="flex flex-col lg:grid grid-cols-12 gap-6 py-12 border-b">
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0">
<h2 id="migrate-to-supabase" className="group scroll-mt-24">
Migrate to Supabase
@@ -322,20 +351,22 @@ const HomePage = () => (
</div>
)}
<div className="flex flex-col gap-6 py-12 border-b">
<div className="col-span-4 flex flex-col gap-1">
<h2 id="additional-resources" className="group scroll-mt-24 m-0">
Additional resources
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
<div className="col-span-4 flex flex-col gap-1 [&_h2]:m-0">
<h2 id="additional-resources" className="group scroll-mt-24">
Explore more
</h2>
<p className="m-0 p-0 text-sm text-foreground-light">
Explore the tools, integrations, and guides that help you get more from Supabase.
</p>
</div>
<ul className="grid grid-cols-12 gap-6 not-prose">
<ul className="col-span-8 grid grid-cols-12 gap-6 not-prose">
{additionalResources.map((resource) => {
return (
<li key={resource.title} className="col-span-12 md:col-span-6 lg:col-span-3">
<li key={resource.title} className="col-span-12 md:col-span-6">
<Link
href={resource.href}
className="col-span-12 md:col-span-6 lg:col-span-3"
passHref
target={resource.external ? '_blank' : undefined}
>
@@ -349,12 +380,12 @@ const HomePage = () => (
</ul>
</div>
{isFeatureEnabled('docs:full_getting_started') && (
<div className="flex flex-col lg:grid grid-cols-12 gap-6 py-12">
<div className="flex flex-col gap-6 py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
<div className="col-span-4 flex flex-col gap-1">
<div className="md:max-w-xs 2xl:max-w-none">
<div className="flex items-center gap-3 mb-3 text-brand-600">
<h2 id="self-hosting" className="group scroll-mt-24 m-0">
Self-Hosting
Self-host Supabase
</h2>
</div>
<p className="text-foreground-light text-sm m-0 p-0">
+3 -3
View File
@@ -1,9 +1,9 @@
'use client'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import React, { type PropsWithChildren } from 'react'
import React, { type ComponentPropsWithoutRef } from 'react'
const DocsCoverLogo = (props: PropsWithChildren) => {
const DocsCoverLogo = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => {
const pathMotionConfig = {
initial: { pathLength: 0 },
animate: { pathLength: 1 },
@@ -24,7 +24,7 @@ const DocsCoverLogo = (props: PropsWithChildren) => {
}
return (
<div className="w-[60px] md:w-[150px] [&_svg]" {...props}>
<div className={className} {...props}>
<LazyMotion features={domAnimation}>
<m.svg
width="100%"
@@ -0,0 +1,9 @@
export const setupCommand = {
installCli: 'npm install -g supabase',
installPlugin: 'npx plugins add supabase-community/supabase-plugin',
initialize: 'supabase init',
} as const
export const setupCommands = [setupCommand.installCli, setupCommand.installPlugin].join('\n')
export const setupPrompt = `Help me get set up with Supabase. Do the following: 1. Install the Supabase CLI globally with \`${setupCommand.installCli}\`. 2. Install the Supabase Plugin with \`${setupCommand.installPlugin}\`. 3. Review my project and determine whether Supabase is already initialized. If it is not initialized, run \`${setupCommand.initialize}\`. 4. Suggest the most relevant next steps.`
+164 -149
View File
@@ -1,180 +1,195 @@
'use client'
// End of third-party imports
import {
Prompt,
PromptContent,
PromptCopy,
PromptPanel,
PromptTitle,
} from '~/features/ui/PromptPanel'
import { isFeatureEnabled, useBreakpoint } from 'common'
import { ChevronRight, Sparkles } from 'lucide-react'
import { Sparkles, Terminal } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { cn } from 'ui'
import { useEffect, useState, type ReactNode } from 'react'
import { IconPanel } from 'ui-patterns/IconPanel'
import { getCustomContent } from '../lib/custom-content/getCustomContent'
import DocsCoverLogo from './DocsCoverLogo'
import { setupCommand, setupCommands, setupPrompt } from './HomePageCover.constants'
const {
sdkDart: sdkDartEnabled,
sdkKotlin: sdkKotlinEnabled,
sdkSwift: sdkSwiftEnabled,
} = isFeatureEnabled(['sdk:dart', 'sdk:kotlin', 'sdk:swift'])
const fullGettingStartedEnabled = isFeatureEnabled('docs:full_getting_started')
function AiPrompt({ className }: { className?: string }) {
function SetupPrompt({ cliCode }: { cliCode: ReactNode }) {
return (
<Link
className={cn(
'group',
'w-fit rounded-full border px-3 py-1 flex gap-2 items-center text-foreground-light text-sm',
'hover:border-brand hover:text-brand-link focus-visible:text-brand-link',
'transition-colors',
className
)}
href="/guides/ai-tools/ai-prompts"
>
<Sparkles size={14} />
Start with Supabase AI prompts
<ChevronRight size={14} className="group-hover:translate-x-1 transition-transform" />
</Link>
<PromptPanel>
<Prompt value="prompt" expandable>
<PromptTitle icon={<Sparkles />}>AI Prompt</PromptTitle>
<PromptCopy>{setupPrompt}</PromptCopy>
<PromptContent>
Help me get set up with Supabase. Do the following: 1. Install the Supabase CLI globally
with{' '}
<code className="shimmer-none rounded bg-surface-200 px-1 py-0.5 font-mono text-xs text-foreground">
{setupCommand.installCli}
</code>
. 2. Install the Supabase Plugin with{' '}
<code className="shimmer-none rounded bg-surface-200 px-1 py-0.5 font-mono text-xs text-foreground">
{setupCommand.installPlugin}
</code>
. 3. Review my project and determine whether Supabase is already initialized. If it is not
initialized, run{' '}
<code className="shimmer-none rounded bg-surface-200 px-1 py-0.5 font-mono text-xs text-foreground">
{setupCommand.initialize}
</code>
. 4. Suggest the most relevant next steps.
</PromptContent>
</Prompt>
<Prompt value="cli">
<PromptTitle icon={<Terminal />}>CLI</PromptTitle>
<PromptCopy>{setupCommands}</PromptCopy>
<PromptContent shimmer={false}>{cliCode}</PromptContent>
</Prompt>
</PromptPanel>
)
}
const HomePageCover = (props) => {
const frameworks = [
{
tooltip: 'ReactJS',
icon: '/docs/img/icons/react-icon',
href: '/guides/getting-started/quickstarts/reactjs',
hasLightIcon: false,
},
{
tooltip: 'Next.js',
icon: '/docs/img/icons/nextjs-icon',
href: '/guides/getting-started/quickstarts/nextjs',
hasLightIcon: false,
},
{
tooltip: 'TanStack Start',
icon: '/docs/img/icons/tanstack-icon',
href: '/guides/getting-started/quickstarts/tanstack',
hasLightIcon: true,
},
{
tooltip: 'Astro.js',
icon: '/docs/img/icons/astro-icon',
href: '/guides/getting-started/quickstarts/astrojs',
hasLightIcon: true,
},
{
tooltip: 'Vue',
icon: '/docs/img/icons/vuejs-icon',
href: '/guides/getting-started/quickstarts/vue',
hasLightIcon: false,
},
{
tooltip: 'Nuxt',
icon: '/docs/img/icons/nuxt-icon',
href: '/guides/getting-started/quickstarts/nuxtjs',
hasLightIcon: false,
},
{
tooltip: 'iOS Swift',
icon: '/docs/img/icons/swift-icon-orange',
href: '/guides/getting-started/quickstarts/ios-swiftui',
enabled: sdkSwiftEnabled,
hasLightIcon: false,
},
{
tooltip: 'Android Kotlin',
icon: '/docs/img/icons/kotlin-icon',
href: '/guides/getting-started/quickstarts/kotlin',
enabled: sdkKotlinEnabled,
hasLightIcon: false,
},
{
tooltip: 'Expo React Native',
icon: '/docs/img/icons/expo-icon',
href: '/guides/getting-started/quickstarts/expo-react-native',
hasLightIcon: true,
},
{
tooltip: 'Flutter',
icon: '/docs/img/icons/flutter-icon',
href: '/guides/getting-started/quickstarts/flutter',
enabled: sdkDartEnabled,
hasLightIcon: false,
},
{
tooltip: 'Python',
icon: '/docs/img/icons/python-icon',
href: '/guides/getting-started/quickstarts/flask',
hasLightIcon: false,
},
]
export function FrameworkQuickstarts() {
const isXs = useBreakpoint(639)
const iconSize = isXs ? 'sm' : 'lg'
const { homepageHeading } = getCustomContent(['homepage:heading'])
const { resolvedTheme } = useTheme()
const isLightMode = !resolvedTheme?.includes('dark')
const [isMounted, setIsMounted] = useState(false)
const isLightMode = isMounted && resolvedTheme === 'light'
const frameworks = [
{
tooltip: 'ReactJS',
icon: '/docs/img/icons/react-icon',
href: '/guides/getting-started/quickstarts/reactjs',
hasLightIcon: false,
},
{
tooltip: 'Next.js',
icon: '/docs/img/icons/nextjs-icon',
href: '/guides/getting-started/quickstarts/nextjs',
hasLightIcon: false,
},
{
tooltip: 'TanStack Start',
icon: '/docs/img/icons/tanstack-icon',
href: '/guides/getting-started/quickstarts/tanstack',
hasLightIcon: true,
},
{
tooltip: 'Astro.js',
icon: '/docs/img/icons/astro-icon',
href: '/guides/getting-started/quickstarts/astrojs',
hasLightIcon: true,
},
{
tooltip: 'Vue',
icon: '/docs/img/icons/vuejs-icon',
href: '/guides/getting-started/quickstarts/vue',
hasLightIcon: false,
},
{
tooltip: 'Nuxt',
icon: '/docs/img/icons/nuxt-icon',
href: '/guides/getting-started/quickstarts/nuxtjs',
hasLightIcon: false,
},
{
tooltip: 'iOS Swift',
icon: '/docs/img/icons/swift-icon-orange',
href: '/guides/getting-started/quickstarts/ios-swiftui',
enabled: sdkSwiftEnabled,
hasLightIcon: false,
},
{
tooltip: 'Android Kotlin',
icon: '/docs/img/icons/kotlin-icon',
href: '/guides/getting-started/quickstarts/kotlin',
enabled: sdkKotlinEnabled,
hasLightIcon: false,
},
{
tooltip: 'Expo React Native',
icon: '/docs/img/icons/expo-icon',
href: '/guides/getting-started/quickstarts/expo-react-native',
hasLightIcon: true,
},
{
tooltip: 'Flutter',
icon: '/docs/img/icons/flutter-icon',
href: '/guides/getting-started/quickstarts/flutter',
enabled: sdkDartEnabled,
hasLightIcon: false,
},
{
tooltip: 'Python',
icon: '/docs/img/icons/python-icon',
href: '/guides/getting-started/quickstarts/flask',
hasLightIcon: false,
},
]
const GettingStarted = () => (
<div
className="
border bg-background
relative overflow-hidden
grid grid-cols-12
rounded-lg
p-5 md:p-8
"
>
<div className="col-span-full flex flex-col md:flex-row xl:flex-col justify-between gap-3">
<div className="md:max-w-xs shrink w-fit xl:max-w-none">
<div className="flex flex-col gap-1 mb-3">
<h2 className="text-2xl text-foreground">Getting Started</h2>
<p className="text-foreground-light text-sm">
Set up and connect a database in just a few minutes.
</p>
</div>
</div>
<div className="shrink-0">
<div className="flex flex-wrap md:grid md:grid-cols-5 gap-2 sm:gap-3">
{frameworks
.filter((framework) => framework.enabled !== false)
.map((framework, i) => {
const iconToUse =
framework.hasLightIcon && isLightMode ? `${framework.icon}-light` : framework.icon
return (
<Link key={i} href={framework.href} passHref className="no-underline">
<IconPanel iconSize={iconSize} tooltip={framework.tooltip} icon={iconToUse} />
</Link>
)
})}
</div>
<AiPrompt className="mt-6" />
</div>
</div>
</div>
)
useEffect(() => setIsMounted(true), [])
return (
<div className="relative z-10 w-full bg-alternative border-b max-w-none mb-16 md:mb-12 xl:mb-0">
<div className="max-w-7xl px-5 mx-auto py-8 sm:pb-16 sm:pt-12 xl:pt-16 flex flex-col xl:flex-row justify-between gap-12 xl:gap-12">
<div className="flex flex-col sm:flex-row gap-4 sm:gap-8 items-start sm:items-center w-full max-w-xl xl:max-w-132">
<DocsCoverLogo aria-hidden="true" />
<div className="flex flex-col">
<h1 className="m-0 mb-3 text-2xl sm:text-3xl text-foreground">
{homepageHeading || props.title}
</h1>
<p className="m-0 text-foreground-light">
Learn how to get up and running with Supabase through tutorials, APIs and platform
resources.
</p>
<div className="grid grid-cols-12 gap-6">
{frameworks
.filter((framework) => framework.enabled !== false)
.map((framework) => {
const iconToUse =
framework.hasLightIcon && isLightMode ? `${framework.icon}-light` : framework.icon
return (
<Link
key={framework.tooltip}
href={framework.href}
passHref
className="col-span-6 no-underline md:col-span-4"
>
<IconPanel
iconSize={iconSize}
tooltip={framework.tooltip}
title={framework.tooltip}
icon={iconToUse}
/>
</Link>
)
})}
</div>
)
}
const HomePageCover = ({ title, cliCode }: { title: string; cliCode: ReactNode }) => {
const { homepageHeading } = getCustomContent(['homepage:heading'])
return (
<div className="w-full border-b bg-muted/10">
<div className="mx-auto max-w-7xl px-6 py-16">
<div className="flex flex-col items-start gap-10 xl:flex-row xl:items-center xl:gap-16">
<div className="flex w-full flex-1 flex-col gap-4 sm:flex-row sm:items-center sm:gap-8">
<DocsCoverLogo aria-hidden="true" className="w-[60px] shrink-0 md:w-[100px]" />
<div className="flex min-w-0 flex-col">
<h1 className="m-0 text-4xl text-foreground">{homepageHeading || title}</h1>
<p className="m-0 mt-3 text-xl leading-7 text-foreground-light">
Learn how to get up and running with Supabase through tutorials, APIs and platform
resources.
</p>
</div>
</div>
{fullGettingStartedEnabled && (
<div className="w-full max-w-xl xl:max-w-[478px] xl:shrink-0">
<SetupPrompt cliCode={cliCode} />
</div>
)}
</div>
{isFeatureEnabled('docs:full_getting_started') && (
<div className="w-full xl:max-w-[440px] -mb-40">
<GettingStarted />
</div>
)}
</div>
</div>
)
+7 -1
View File
@@ -5,8 +5,8 @@ import AuthProviders from '~/components/AuthProviders'
import { AuthSmsProviderConfig } from '~/components/AuthSmsProviderConfig'
import ButtonCard from '~/components/ButtonCard'
import { ComputeDiskLimitsTable } from '~/components/ComputeDiskLimitsTable'
import { CustomContent } from '~/components/CustomContent'
import { ContentListings } from '~/components/ContentListings'
import { CustomContent } from '~/components/CustomContent'
import { Extensions } from '~/components/Extensions'
import Image, { type ImageProps } from '~/components/Image'
import { McpCiConfigBlock } from '~/components/McpCiConfigBlock'
@@ -40,6 +40,7 @@ import { Heading } from 'ui/src/components/CustomHTMLElements'
import { AgentPluginsPanel } from '../ui/AgentPluginsPanel'
import { ErrorCodes } from '../ui/ErrorCodes'
import { McpConfigPanel } from '../ui/McpConfigPanel'
import { Prompt, PromptContent, PromptCopy, PromptPanel, PromptTitle } from '../ui/PromptPanel'
// Wrap Admonition for Docs-specific styling (within MDX prose, requires a margin-bottom)
const AdmonitionWithMargin = (props: AdmonitionProps) => {
@@ -96,6 +97,11 @@ const components = {
NamedCodeBlock,
NavData,
ProjectConfigVariables,
Prompt,
PromptContent,
PromptCopy,
PromptPanel,
PromptTitle,
RealtimeLimitsEstimator,
RegionsList,
SmartRegionsList,
@@ -26,12 +26,14 @@ export async function CodeBlock({
contents,
children,
skipTypeGeneration,
hideControls = false,
}: PropsWithChildren<{
className?: string
lang?: string
lineNumbers?: boolean
contents?: string
skipTypeGeneration?: boolean
hideControls?: boolean
}>) {
let code = (contents || extractCode(children)).trim()
const lang = tryToBundledLanguage(langSetting || '') || extractLang(children)
@@ -73,7 +75,7 @@ export async function CodeBlock({
role="group"
aria-roledescription="code block"
>
<CodeBlockControls content={code.trim()} />
{!hideControls && <CodeBlockControls content={code.trim()} />}
<pre>
<code className={lineNumbers ? 'grid grid-cols-[auto_1fr]' : ''}>
{lineNumbers ? (
+304
View File
@@ -0,0 +1,304 @@
'use client'
import { Check, Copy } from 'lucide-react'
import {
Children,
isValidElement,
useEffect,
useId,
useState,
type ReactElement,
type ReactNode,
} from 'react'
// shadcn tabs from packages/ui/src/components/shadcn/ui/tabs.tsx
import { cn, copyToClipboard, Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
type PromptTitleProps = {
children: ReactNode
icon?: ReactNode
}
type PromptCopyProps = {
children: string
}
type PromptContentProps = {
children: ReactNode
/** Draws attention with a text shimmer. Defaults to true. */
shimmer?: boolean
}
type PromptProps = {
children: ReactNode
/** Stable tab id. Auto-generated when omitted. */
value?: string
/** When true, content starts collapsed with a show more / show less control. */
expandable?: boolean
}
type PromptPanelProps = {
children: ReactNode
className?: string
}
type CollectedPrompt = {
value: string
title: ReactNode
icon?: ReactNode
copyValue: string
content: ReactNode
expandable: boolean
shimmer: boolean
}
function PromptTitle(_props: PromptTitleProps) {
return null
}
PromptTitle.displayName = 'PromptTitle'
function PromptCopy(_props: PromptCopyProps) {
return null
}
PromptCopy.displayName = 'PromptCopy'
function PromptContent(_props: PromptContentProps) {
return null
}
PromptContent.displayName = 'PromptContent'
function Prompt(_props: PromptProps) {
return null
}
Prompt.displayName = 'Prompt'
function isElementOfType(child: ReactNode, type: { displayName?: string }) {
return (
isValidElement(child) &&
typeof child.type !== 'string' &&
'displayName' in child.type &&
child.type.displayName === type.displayName
)
}
function collectPrompt(prompt: ReactElement<PromptProps>, index: number): CollectedPrompt {
let title: ReactNode = `Prompt ${index + 1}`
let icon: ReactNode | undefined
let copyValue = ''
let content: ReactNode = null
let shimmer = true
Children.forEach(prompt.props.children, (child) => {
if (isElementOfType(child, PromptTitle)) {
const props = (child as ReactElement<PromptTitleProps>).props
title = props.children
icon = props.icon
} else if (isElementOfType(child, PromptCopy)) {
copyValue = (child as ReactElement<PromptCopyProps>).props.children
} else if (isElementOfType(child, PromptContent)) {
const props = (child as ReactElement<PromptContentProps>).props
content = props.children
shimmer = props.shimmer !== false
}
})
return {
value: prompt.props.value ?? `prompt-${index}`,
title,
icon,
copyValue,
content,
expandable: Boolean(prompt.props.expandable),
shimmer,
}
}
function collectPrompts(children: ReactNode): CollectedPrompt[] {
return Children.toArray(children)
.filter((child): child is ReactElement<PromptProps> => isElementOfType(child, Prompt))
.map((prompt, index) => collectPrompt(prompt, index))
}
function ExpandableContent({ children }: { children: ReactNode }) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div>
<div className="relative">
<div className={cn(!isExpanded && 'max-h-30 overflow-hidden')}>{children}</div>
{!isExpanded && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-background to-transparent" />
)}
</div>
<button
type="button"
onClick={() => setIsExpanded((expanded) => !expanded)}
className="mt-2 text-sm text-brand-link transition-colors hover:text-brand focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground-muted"
aria-expanded={isExpanded}
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
</div>
)
}
function PromptBody({
prompt,
shimmerEnabled,
}: {
prompt: CollectedPrompt
shimmerEnabled: boolean
}) {
const [shimmerMounted, setShimmerMounted] = useState(prompt.shimmer)
const content = (
<div
className={cn(shimmerMounted && 'shimmer')}
data-shimmer-fading={shimmerMounted && !shimmerEnabled ? true : undefined}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget) return
if (shimmerMounted && !shimmerEnabled) {
setShimmerMounted(false)
}
}}
>
{prompt.content}
</div>
)
return (
<div className="px-4 py-3.5 text-sm leading-6 text-foreground-light">
{prompt.expandable ? <ExpandableContent>{content}</ExpandableContent> : content}
</div>
)
}
function CopyButton({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
const timeout = window.setTimeout(() => setCopied(false), 2000)
return () => window.clearTimeout(timeout)
}, [copied])
return (
<button
type="button"
onClick={() => {
copyToClipboard(value, () => {
setCopied(true)
})
}}
className="rounded-sm p-1.5 text-foreground-muted transition-colors hover:bg-surface-200 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground-muted"
aria-label={copied ? `${label} copied` : `Copy ${label}`}
title={copied ? 'Copied' : 'Copy to clipboard'}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
)
}
function TabLabel({ icon, children }: { icon?: ReactNode; children: ReactNode }) {
return (
<span className="inline-flex items-center gap-1.5">
{icon && <span className="text-tertiary-foreground [&_svg]:size-3.5">{icon}</span>}
{children}
</span>
)
}
const tabTriggerClassName = 'h-full px-0 py-0 text-xs shadow-none data-[state=active]:shadow-none'
/**
* Copyable prompt card. Compose one or more `<Prompt>` children; multiple
* prompts render as tabs. A single prompt still shows the header + copy
* button, with the title styled like an inactive tab (no underline).
*
* @example
* ```tsx
* <PromptPanel>
* <Prompt value="prompt" expandable>
* <PromptTitle icon={<Sparkles />}>AI Prompt</PromptTitle>
* <PromptCopy>Plain text copied to the clipboard</PromptCopy>
* <PromptContent>Rich content shown in the panel</PromptContent>
* </Prompt>
* </PromptPanel>
* ```
*/
function PromptPanel({ children, className }: PromptPanelProps) {
const fallbackId = useId()
const prompts = collectPrompts(children)
const [activeTab, setActiveTab] = useState(prompts[0]?.value ?? fallbackId)
const [shimmerEnabled, setShimmerEnabled] = useState(true)
if (prompts.length === 0) return null
const activePrompt = prompts.find((prompt) => prompt.value === activeTab) ?? prompts[0]
const hasTabs = prompts.length > 1
const dismissShimmer = () => setShimmerEnabled(false)
const header = (
<div className="flex h-11 items-center justify-between border-b bg-surface-75 px-4">
{hasTabs ? (
<TabsList className="h-full gap-5 border-0">
{prompts.map((prompt) => (
<TabsTrigger key={prompt.value} value={prompt.value} className={tabTriggerClassName}>
<TabLabel icon={prompt.icon}>{prompt.title}</TabLabel>
</TabsTrigger>
))}
</TabsList>
) : (
<span
className={cn(
'inline-flex items-center justify-center whitespace-nowrap border-b-2 border-transparent text-foreground-lighter',
tabTriggerClassName
)}
>
<TabLabel icon={prompts[0].icon}>{prompts[0].title}</TabLabel>
</span>
)}
<CopyButton
key={activePrompt.value}
label={typeof activePrompt.title === 'string' ? activePrompt.title : 'content'}
value={activePrompt.copyValue}
/>
</div>
)
if (!hasTabs) {
return (
<div
onFocusCapture={dismissShimmer}
onPointerEnter={dismissShimmer}
className={cn(
'w-full overflow-hidden rounded-lg border bg-background shadow-sm',
className
)}
>
{header}
<PromptBody prompt={prompts[0]} shimmerEnabled={shimmerEnabled} />
</div>
)
}
return (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
onFocusCapture={dismissShimmer}
onPointerEnter={dismissShimmer}
className={cn('w-full overflow-hidden rounded-lg border bg-background shadow-sm', className)}
>
{header}
{prompts.map((prompt) => (
<TabsContent key={prompt.value} value={prompt.value} className="m-0">
<PromptBody prompt={prompt} shimmerEnabled={shimmerEnabled} />
</TabsContent>
))}
</Tabs>
)
}
export { Prompt, PromptContent, PromptCopy, PromptPanel, PromptTitle }
export type { PromptContentProps, PromptCopyProps, PromptPanelProps, PromptProps, PromptTitleProps }
@@ -28,6 +28,7 @@ import { MetricsStackCards } from './markdown-schema/MetricsStackCards'
import { NavData } from './markdown-schema/NavData'
import { Panel } from './markdown-schema/Panel'
import { Price } from './markdown-schema/Price'
import { PromptPanel } from './markdown-schema/PromptPanel'
import { RealtimeLimitsEstimator } from './markdown-schema/RealtimeLimitsEstimator'
import { RegionsList, SmartRegionsList } from './markdown-schema/RegionsList'
import { SharedData } from './markdown-schema/SharedData'
@@ -174,6 +175,7 @@ const SCHEMA: ComponentSchema = {
McpCiConfigBlock,
McpConfigPanel,
Price,
...PromptPanel,
GlassPanel: Panel,
IconPanel: Panel,
RealtimeLimitsEstimator,
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { PromptPanel } from './PromptPanel'
function serializePrompt({
title,
copyValue,
content,
}: {
title: string
copyValue: string
content: string
}) {
const children = [
PromptPanel.PromptTitle({ children: title }),
PromptPanel.PromptCopy({ children: copyValue }),
PromptPanel.PromptContent({ children: content }),
]
.filter(Boolean)
.join('\n\n')
return PromptPanel.Prompt({ children })
}
describe('PromptPanel markdown schema', () => {
it('flattens prompts in order while preserving visible rich content', () => {
const prompt = serializePrompt({
title: 'AI Prompt',
copyValue: 'clipboard-only prompt payload',
content:
'Install the CLI with `npm install -g supabase` and read the [CLI guide](/docs/guides/local-development/cli/getting-started).',
})
const cli = serializePrompt({
title: 'CLI',
copyValue: 'clipboard-only CLI payload',
content: '```bash\nsupabase init\n```',
})
const markdown = PromptPanel.PromptPanel({ children: [prompt, cli].join('\n\n') })
expect(markdown).toBe(`**AI Prompt**
Install the CLI with \`npm install -g supabase\` and read the [CLI guide](/docs/guides/local-development/cli/getting-started).
**CLI**
\`\`\`bash
supabase init
\`\`\``)
expect(markdown).not.toContain('clipboard-only')
})
it('omits empty titles without dropping prompt content', () => {
const markdown = serializePrompt({
title: ' ',
copyValue: 'clipboard-only content',
content: 'Visible content',
})
expect(markdown).toBe('Visible content')
})
it('registers every PromptPanel marker component', () => {
expect(Object.keys(PromptPanel)).toEqual([
'PromptPanel',
'Prompt',
'PromptTitle',
'PromptCopy',
'PromptContent',
])
})
})
@@ -0,0 +1,22 @@
type HandlerContext = {
children: string
}
const preserveChildren = ({ children }: HandlerContext): string => children
const PromptTitle = ({ children }: HandlerContext): string => {
const title = children.trim()
return title ? `**${title}**` : ''
}
// PromptCopy is clipboard-only metadata. PromptContent is the visible source of
// truth and is preserved below, so including both would duplicate the prompt.
const PromptCopy = (_context: HandlerContext): string => ''
export const PromptPanel = {
PromptPanel: preserveChildren,
Prompt: preserveChildren,
PromptTitle,
PromptCopy,
PromptContent: preserveChildren,
}
+16 -3
View File
@@ -1,6 +1,8 @@
import HomePageCover from '~/components/HomePageCover'
import { setupCommands } from '~/components/HomePageCover.constants'
import { CodeBlock } from '~/features/ui/CodeBlock/CodeBlock'
import { type PropsWithChildren } from 'react'
import HomePageCover from '~/components/HomePageCover'
import { LayoutMainContent } from './DefaultLayout'
import { SidebarSkeleton } from './MainSkeleton'
@@ -8,8 +10,19 @@ const HomeLayout = ({ children }: PropsWithChildren) => {
return (
<SidebarSkeleton hideSideNav>
<article>
<HomePageCover title="Supabase Documentation" />
<LayoutMainContent>
<HomePageCover
title="Supabase Documentation"
cliCode={
<CodeBlock
contents={setupCommands}
lang="bash"
lineNumbers={false}
hideControls
className="!-mx-4 !-my-3.5 !border-0 !rounded-none !bg-transparent [&_.code-content]:!px-4 [&_.code-content]:!py-3.5"
/>
}
/>
<LayoutMainContent className="max-w-7xl">
<div className={['relative transition-all ease-out', 'duration-150 '].join(' ')}>
<div className="prose max-w-none">{children}</div>
</div>
+9
View File
@@ -296,3 +296,12 @@
height: 0;
}
}
@keyframes tw-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: 0 0;
}
}
+106
View File
@@ -74,3 +74,109 @@
display: none; /* Chrome, Safari, Opera */
}
}
/*
* Text shimmer (shadcn shimmer utility, inlined so we don't pull in the full
* shadcn CSS package). Apply `shimmer` to text to draw attention.
*/
@property --shimmer-angle {
syntax: '<angle>';
inherits: true;
initial-value: 20deg;
}
@property --shimmer-image {
syntax: '*';
inherits: false;
}
@property --shimmer-text-fill {
syntax: '*';
inherits: false;
}
@utility shimmer {
--_spread: var(--shimmer-spread, calc(3ch + 40px));
--_base: currentColor;
--_highlight: var(--shimmer-color, oklch(from currentColor l c h / calc(alpha* 0.2)));
background-image: var(
--shimmer-image,
linear-gradient(
calc(90deg + var(--shimmer-angle)),
var(--_base) calc(50% - var(--_spread)),
color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% - var(--_spread) * 0.5),
var(--_highlight) 50%,
color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% + var(--_spread) * 0.5),
var(--_base) calc(50% + var(--_spread))
)
);
background-repeat: no-repeat;
background-size: calc(200% + var(--_spread) * 2) 100%;
background-position: 0 0;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: var(--shimmer-text-fill, transparent);
animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite;
transition: -webkit-text-fill-color 0.5s ease;
/* Fade to solid text on dismiss — keep the gradient until fill is opaque */
&[data-shimmer-fading] {
-webkit-text-fill-color: currentColor;
animation-play-state: paused;
}
@variant dark {
--_highlight: var(
--shimmer-color,
oklch(from currentColor max(0.8, calc(l + 0.4)) c h / calc(alpha + 0.4))
);
}
&:where([dir='rtl'], [dir='rtl'] *) {
animation-direction: reverse;
}
}
@utility shimmer-once {
animation-iteration-count: 1;
}
@utility shimmer-reverse {
animation-direction: reverse;
}
@utility shimmer-none {
--shimmer-image: none;
--shimmer-text-fill: currentColor;
background-image: none;
-webkit-text-fill-color: currentColor;
}
@utility shimmer-color-* {
--shimmer-color: --value(--color-*, [color]);
--shimmer-color: color-mix(
in oklch,
--value(--color-*, [color]) calc(--modifier(integer) * 1%),
transparent
);
}
@utility shimmer-duration-* {
--shimmer-duration: calc(--value(integer) * 1ms);
}
@utility shimmer-spread-* {
--shimmer-spread: calc(var(--spacing) * --value(integer));
--shimmer-spread: --value([length], [percentage]);
}
@utility shimmer-angle-* {
--shimmer-angle: calc(--value(integer) * 1deg);
}
@media (prefers-reduced-motion: reduce) {
.shimmer {
animation: none;
background-image: none;
-webkit-text-fill-color: currentColor;
}
}