fix: rework v0 init route

This commit is contained in:
shadcn
2026-03-04 08:49:23 +04:00
parent 2224411358
commit cb6e798b90
6 changed files with 155 additions and 150 deletions

View File

@@ -79,7 +79,7 @@ export function Customizer({
</FieldGroup>
</CardContent>
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:**:[button,a]:w-full">
<CopyPreset className="h-9 max-w-42 flex-1 md:max-w-none md:flex-none" />
<CopyPreset className="h-8 max-w-42 flex-1 md:max-w-none md:flex-none" />
<V0Button className="ml-auto max-w-42 flex-1 md:hidden" />
<RandomButton className="hidden md:flex" />
<ActionMenu itemsByBase={itemsByBase} />

View File

@@ -124,19 +124,10 @@ export function ProjectForm({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button
className={cn(
"pointer-coarse:h-10! pointer-coarse:text-sm!",
className
)}
/>
}
>
<DialogTrigger render={<Button className={cn("", className)} />}>
Create Project
</DialogTrigger>
<DialogContent className="min-w-0 p-6 sm:max-w-md">
<DialogContent className="min-w-0 sm:max-w-sm">
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription>
@@ -198,58 +189,64 @@ export function ProjectForm({
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter className="-mx-6 mt-2 -mb-6 flex min-w-0 flex-col gap-3 border-t bg-muted/30 p-6 sm:flex-col">
<Tabs
value={packageManager}
onValueChange={(value) => {
setConfig((prev) => ({
...prev,
packageManager: value as PackageManager,
}))
}}
className="min-w-0 gap-0 overflow-hidden rounded-lg border bg-surface"
>
<div className="flex items-center gap-2 px-1.5 py-1">
<TabsList className="h-auto rounded-none bg-transparent p-0 font-mono group-data-[orientation=horizontal]/tabs:h-8 *:data-[slot=tabs-trigger]:h-7 *:data-[slot=tabs-trigger]:border *:data-[slot=tabs-trigger]:border-transparent *:data-[slot=tabs-trigger]:pt-0.5 *:data-[slot=tabs-trigger]:shadow-none! *:data-[slot=tabs-trigger]:data-[state=active]:border-input">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger key={manager} value={manager}>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto size-7"
onClick={handleCopy}
>
{hasCopied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(commands).map(([key, cmd]) => {
return (
<TabsContent key={key} value={key}>
<div className="relative overflow-hidden border-t border-border/50 bg-surface px-3 py-3 text-surface-foreground">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
<DialogFooter className="min-w-0">
<div className="over flex w-full min-w-0 flex-col gap-3">
<Tabs
value={packageManager}
onValueChange={(value) => {
setConfig((prev) => ({
...prev,
packageManager: value as PackageManager,
}))
}}
className="min-w-0 gap-0 overflow-hidden rounded-lg border bg-surface"
>
<div className="flex items-center gap-2 px-1 py-1">
<TabsList className="font-mono">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger
key={manager}
value={manager}
className="data-[state=active]:shadow-none"
>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
>
{hasCopied ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(commands).map(([key, cmd]) => {
return (
<TabsContent key={key} value={key}>
<div className="relative overflow-hidden border-t border-border/50 bg-surface px-3 py-3 text-surface-foreground">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
</div>
</div>
</div>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopy} className="h-9 w-full">
Copy Command
</Button>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopy} className="h-9 w-full">
{hasCopied ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -288,17 +285,17 @@ const TemplateGrid = React.memo(function TemplateGrid({
<FieldLabel
key={template.value}
htmlFor={`template-${template.value}`}
className="py-2"
className="py-1"
>
<Field className="gap-0" orientation="horizontal">
<FieldContent className="flex flex-col items-center justify-center gap-2">
<div
className="size-8 text-foreground [&_svg]:size-8 *:[svg]:text-foreground!"
className="size-6 text-foreground [&_svg]:size-6 *:[svg]:text-foreground!"
dangerouslySetInnerHTML={{
__html: template.logo,
}}
></div>
<FieldTitle>{template.title}</FieldTitle>
<FieldTitle className="text-xs">{template.title}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={template.value}

View File

@@ -22,7 +22,7 @@ export function RandomButton({
variant={variant}
onClick={randomize}
className={cn(
"h-17! w-42 touch-manipulation justify-between rounded-xl bg-transparent! p-4 text-sm! select-none hover:bg-muted! md:h-9! md:rounded-lg md:px-2!",
"h-17! w-42 touch-manipulation justify-between rounded-xl bg-transparent! p-4 text-sm! select-none hover:bg-muted! md:h-8! md:rounded-lg md:px-2! md:py-0!",
className
)}
{...props}

View File

@@ -15,22 +15,21 @@ export function V0Button({ className }: { className?: string }) {
const isMobile = useIsMobile()
const isMounted = useMounted()
const url = React.useMemo(
() =>
`${process.env.NEXT_PUBLIC_APP_URL}/create/v0?base=${params.base}&style=${params.style}&baseColor=${params.baseColor}&theme=${params.theme}&iconLibrary=${params.iconLibrary}&font=${params.font}&menuAccent=${params.menuAccent}&menuColor=${params.menuColor}&radius=${params.radius}&item=${params.item}`,
[
params.base,
params.style,
params.baseColor,
params.theme,
params.iconLibrary,
params.font,
params.menuAccent,
params.menuColor,
params.radius,
params.item,
]
)
const url = React.useMemo(() => {
const searchParams = new URLSearchParams()
if (params.preset) {
searchParams.set("preset", params.preset)
}
searchParams.set("base", params.base)
if (params.item) {
searchParams.set("item", params.item)
}
return `${process.env.NEXT_PUBLIC_APP_URL}/init/v0?${searchParams.toString()}`
}, [params.preset, params.base, params.item])
if (!isMounted) {
return <Skeleton className="h-8 w-24 rounded-lg" />
@@ -45,12 +44,7 @@ export function V0Button({ className }: { className?: string }) {
"gap-1 pointer-coarse:h-10! pointer-coarse:text-sm!",
className
)}
render={
<a
href={`${process.env.NEXT_PUBLIC_V0_URL}/chat/api/open?url=${encodeURIComponent(url)}&title=${params.item}`}
target="_blank"
/>
}
render={<a href={url} target="_blank" />}
>
<span>Open in</span>
<Icons.v0 className="size-5" data-icon="inline-end" />

View File

@@ -0,0 +1,33 @@
import { after, NextResponse, type NextRequest } from "next/server"
import { track } from "@vercel/analytics/server"
import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config"
import { buildV0Payload } from "@/app/(create)/lib/build-payload"
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const result = parseDesignSystemConfig(searchParams)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
// Defer analytics to after response is sent.
after(() => {
track("create_open_in_v0", result.data)
})
const payload = await buildV0Payload(result.data)
return NextResponse.json(payload)
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "An unknown error occurred",
},
{ status: 500 }
)
}
}

View File

@@ -1,5 +1,3 @@
import { after, NextResponse, type NextRequest } from "next/server"
import { track } from "@vercel/analytics/server"
import dedent from "dedent"
import {
registryItemFileSchema,
@@ -13,59 +11,14 @@ import { z } from "zod"
import {
buildRegistryBase,
designSystemConfigSchema,
fonts,
type DesignSystemConfig,
} from "@/registry/config"
const { Index } = await import("@/registry/bases/__index__")
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const parseResult = designSystemConfigSchema.safeParse({
base: searchParams.get("base"),
style: searchParams.get("style"),
iconLibrary: searchParams.get("iconLibrary"),
baseColor: searchParams.get("baseColor"),
theme: searchParams.get("theme"),
font: searchParams.get("font"),
item: searchParams.get("item"),
menuAccent: searchParams.get("menuAccent"),
menuColor: searchParams.get("menuColor"),
radius: searchParams.get("radius"),
})
if (!parseResult.success) {
return NextResponse.json(
{ error: parseResult.error.issues[0].message },
{ status: 400 }
)
}
const designSystemConfig = parseResult.data
// Defer analytics to after response is sent. (server-after-nonblocking)
after(() => {
track("create_open_in_v0", designSystemConfig)
})
const payload = await buildV0Payload(designSystemConfig)
return NextResponse.json(payload)
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "An unknown error occurred",
},
{ status: 500 }
)
}
}
async function buildV0Payload(designSystemConfig: DesignSystemConfig) {
// Builds a full v0 payload from a design system config.
export async function buildV0Payload(designSystemConfig: DesignSystemConfig) {
const registryBase = buildRegistryBase(designSystemConfig)
// Build all files in parallel.
@@ -78,6 +31,7 @@ async function buildV0Payload(designSystemConfig: DesignSystemConfig) {
return registryItemSchema.parse({
name: designSystemConfig.item ?? "Item",
type: "registry:item",
dependencies: registryBase.dependencies,
files: [globalsCss, layoutFile, ...componentFiles],
})
}
@@ -100,6 +54,7 @@ function buildGlobalsCss(registryBase: RegistryItem) {
@theme inline {
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -175,12 +130,27 @@ function buildLayoutFile(designSystemConfig: DesignSystemConfig) {
throw new Error(`Font "${designSystemConfig.font}" not found`)
}
// Derive const name from the font's CSS variable (e.g. --font-sans → fontSans).
const constName = font.font.variable
.replace(/^--/, "")
.replace(/-./g, (m) => m[1].toUpperCase())
// Add font-serif or font-mono class to body when needed. Sans is the default.
const fontClass =
font.font.variable === "--font-serif"
? "font-serif"
: font.font.variable === "--font-mono"
? "font-mono"
: ""
const bodyClassName = fontClass ? `antialiased ${fontClass}` : "antialiased"
const content = dedent`
import type { Metadata } from "next";
import { ${font.font.import} } from "next/font/google";
import "./globals.css";
const fontSans = ${font.font.import}({subsets:['latin'],variable:'--font-sans'});
const ${constName} = ${font.font.import}({subsets:['latin'],variable:'${font.font.variable}'});
export const metadata: Metadata = {
title: "Create Next App",
@@ -193,9 +163,9 @@ function buildLayoutFile(designSystemConfig: DesignSystemConfig) {
children: React.ReactNode;
}>) {
return (
<html lang="en" className={fontSans.variable}>
<html lang="en" className={${constName}.variable}>
<body
className="antialiased"
className="${bodyClassName}"
>
{children}
</body>
@@ -215,13 +185,10 @@ function buildLayoutFile(designSystemConfig: DesignSystemConfig) {
async function buildComponentFiles(designSystemConfig: DesignSystemConfig) {
const files = []
const allItemsForBase = Object.values(Index[designSystemConfig.base])
.filter(
(item: RegistryItem) =>
item.type === "registry:ui" || item.name === "example"
)
.filter((item: RegistryItem) => item.type === "registry:ui")
.map((item) => item.name)
// Fetch UI components and the item component in parallel. (async-parallel)
// Fetch UI components and the item component in parallel.
const itemComponentPromise = designSystemConfig.item
? getRegistryItemFile(designSystemConfig.item, designSystemConfig)
: null
@@ -236,9 +203,23 @@ async function buildComponentFiles(designSystemConfig: DesignSystemConfig) {
type: "registry:page",
target: "app/page.tsx",
content: dedent`
import { Button } from "@/components/ui/button";
export default function Page() {
return <Button>Click me</Button>
return (
<div className="flex min-h-svh p-6">
<div className="max-w-md text-sm leading-loose">
<h1 className="font-medium">Project ready!</h1>
<p>You may now prompt v0 to start building.</p>
<a
href="https://ui.shadcn.com/docs/new"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex text-primary underline underline-offset-4"
>
Read the docs
</a>
</div>
</div>
)
}
`,
}
@@ -296,7 +277,7 @@ async function getRegistryItemFile(
const json = await response.json()
const item = registryItemSchema.parse(json)
// Build a v0 config i.e components.json
// Build a v0 config i.e components.json.
const config = {
$schema: "https://ui.shadcn.com/schema.json",
style: `${designSystemConfig.base}-${designSystemConfig.style}`,
@@ -351,7 +332,7 @@ async function getRegistryItemFile(
const transformers = [transformIcons, transformMenu, transformRender]
// Reuse a single ts-morph Project — avoids re-creating the compiler host per file. (js-cache-function-results)
// Reuse a single ts-morph Project — avoids re-creating the compiler host per file.
const project = new Project({ compilerOptions: {} })
async function transformFileContent(