mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-01 16:44:24 +00:00
fix: rework v0 init route
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
33
apps/v4/app/(create)/init/v0/route.ts
Normal file
33
apps/v4/app/(create)/init/v0/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
Reference in New Issue
Block a user