snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
All checks were successful
Release / Create Version PR or Publish Stable Release (push) Has been skipped
Release / Publish Prerelease to NPM (push) Has been skipped
Monitor Registries / Monitor registry health (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 22:44:43 +05:00
commit 1416db152c
5727 changed files with 630238 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
"use client"
import { MENU_ACCENTS, type MenuAccentValue } from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { usePreviewOverride } from "@/app/(app)/(create)/components/preview-override"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function MenuAccentPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const { setOverride, clearOverride } = usePreviewOverride()
const currentAccent = MENU_ACCENTS.find(
(accent) => accent.value === params.menuAccent
)
return (
<div className="group/picker relative pr-3 md:pr-0">
<Picker
onOpenChange={(open) => {
if (!open) {
clearOverride()
}
}}
>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Menu Accent</div>
<div className="text-sm font-medium text-foreground">
{currentAccent?.label}
</div>
</div>
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="128"
height="128"
viewBox="0 0 24 24"
fill="none"
className="size-4 text-foreground"
>
<path
d="M19 12.1294L12.9388 18.207C11.1557 19.9949 10.2641 20.8889 9.16993 20.9877C8.98904 21.0041 8.80705 21.0041 8.62616 20.9877C7.53195 20.8889 6.64039 19.9949 4.85726 18.207L2.83687 16.1811C1.72104 15.0622 1.72104 13.2482 2.83687 12.1294M19 12.1294L10.9184 4.02587M19 12.1294H2.83687M10.9184 4.02587L2.83687 12.1294M10.9184 4.02587L8.89805 2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
data-accent={currentAccent?.value}
className="fill-muted-foreground/30 data-[accent=bold]:fill-foreground"
></path>
<path
d="M22 20C22 21.1046 21.1046 22 20 22C18.8954 22 18 21.1046 18 20C18 18.8954 20 17 20 17C20 17 22 18.8954 22 20Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
data-accent={currentAccent?.value}
className="fill-muted-foreground/30 data-[accent=bold]:fill-foreground"
></path>
</svg>
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
onMouseLeave={clearOverride}
>
<PickerRadioGroup
value={currentAccent?.value}
onValueChange={(value) => {
setParams({ menuAccent: value as MenuAccentValue })
}}
onItemPreview={
isMobile
? undefined
: (value) =>
setOverride({ menuAccent: value as MenuAccentValue })
}
>
<PickerGroup>
{MENU_ACCENTS.map((accent) => (
<PickerRadioItem
key={accent.value}
value={accent.value}
closeOnClick={isMobile}
disabled={
accent.value === "bold" &&
(params.menuColor === "default-translucent" ||
params.menuColor === "inverted-translucent")
}
>
{accent.label}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="menuAccent"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -0,0 +1,88 @@
"use client"
import Script from "next/script"
import { type RegistryItem } from "shadcn/schema"
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/styles/base-nova/ui/command"
import { useActionMenu } from "@/app/(app)/(create)/hooks/use-action-menu"
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"
export function ActionMenu({
itemsByBase,
}: {
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
}) {
const {
activeRegistryName,
getCommandValue,
groups,
handleSelect,
open,
setOpen,
} = useActionMenu(itemsByBase)
return (
<CommandDialog open={open} onOpenChange={setOpen} className="animate-none!">
<Command loop>
<CommandInput placeholder="Search" />
<CommandList>
<CommandEmpty>No items found.</CommandEmpty>
<CommandGroup>
{groups.map((group) =>
group.items.map((item) => (
<CommandItem
key={item.id}
value={getCommandValue(item)}
data-checked={activeRegistryName === item.registryName}
className="px-2"
onSelect={() => {
handleSelect(item.registryName)
}}
>
{item.label}
</CommandItem>
))
)}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
)
}
export function ActionMenuScript() {
return (
<Script
id="design-system-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward Cmd/Ctrl + K (and P) to parent
document.addEventListener('keydown', function(e) {
if ((e.key === 'k' || e.key === 'p') && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${CMD_K_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -0,0 +1,100 @@
"use client"
import * as React from "react"
import { useMounted } from "@/hooks/use-mounted"
import { BASE_COLORS, type BaseColorName } from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { usePreviewOverride } from "@/app/(app)/(create)/components/preview-override"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function BaseColorPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const mounted = useMounted()
const [params, setParams] = useDesignSystemSearchParams()
const { setOverride, clearOverride } = usePreviewOverride()
const currentBaseColor = React.useMemo(
() => BASE_COLORS.find((baseColor) => baseColor.name === params.baseColor),
[params.baseColor]
)
return (
<div className="group/picker relative">
<Picker
onOpenChange={(open) => {
if (!open) {
clearOverride()
}
}}
>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Base Color</div>
<div className="text-sm font-medium text-foreground">
{currentBaseColor?.title}
</div>
</div>
{mounted && (
<div
style={
{
"--color":
currentBaseColor?.cssVars?.dark?.["muted-foreground"],
} as React.CSSProperties
}
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 rounded-full bg-(--color) select-none md:right-2.5"
/>
)}
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
onMouseLeave={clearOverride}
>
<PickerRadioGroup
value={currentBaseColor?.name}
onValueChange={(value) => {
setParams({ baseColor: value as BaseColorName })
}}
onItemPreview={
isMobile
? undefined
: (value) => setOverride({ baseColor: value as BaseColorName })
}
>
<PickerGroup>
{BASE_COLORS.map((baseColor) => (
<PickerRadioItem
key={baseColor.name}
value={baseColor.name}
closeOnClick={isMobile}
>
{baseColor.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="baseColor"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -0,0 +1,86 @@
"use client"
import * as React from "react"
import { BASES } from "@/registry/config"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function BasePicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentBase = React.useMemo(
() => BASES.find((base) => base.name === params.base),
[params.base]
)
const handleValueChange = React.useCallback(
(value: string) => {
const newBase = BASES.find((base) => base.name === value)
if (!newBase) {
return
}
setParams({ base: newBase.name })
},
[setParams]
)
return (
<div className="group/picker relative">
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Base</div>
<div className="text-sm font-medium text-foreground">
{currentBase?.title}
</div>
</div>
{currentBase?.meta?.logo && (
<div
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 text-foreground select-none md:right-2.5 *:[svg]:size-4 *:[svg]:text-foreground!"
dangerouslySetInnerHTML={{
__html: currentBase.meta.logo,
}}
/>
)}
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
>
<PickerRadioGroup
value={currentBase?.name}
onValueChange={handleValueChange}
>
<PickerGroup>
{BASES.map((base) => (
<PickerRadioItem
key={base.name}
value={base.name}
closeOnClick={isMobile}
>
{base.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
</div>
)
}

View File

@@ -0,0 +1,145 @@
"use client"
import * as React from "react"
import { useMounted } from "@/hooks/use-mounted"
import {
BASE_COLORS,
getThemesForBaseColor,
type ChartColorName,
} from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { usePreviewOverride } from "@/app/(app)/(create)/components/preview-override"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function ChartColorPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const mounted = useMounted()
const [params, setParams] = useDesignSystemSearchParams()
const { setOverride, clearOverride } = usePreviewOverride()
const availableChartColors = React.useMemo(
() => getThemesForBaseColor(params.baseColor),
[params.baseColor]
)
const currentChartColor = React.useMemo(
() =>
availableChartColors.find((theme) => theme.name === params.chartColor),
[availableChartColors, params.chartColor]
)
const currentChartColorIsBaseColor = React.useMemo(
() => BASE_COLORS.find((baseColor) => baseColor.name === params.chartColor),
[params.chartColor]
)
return (
<div className="group/picker relative">
<Picker
onOpenChange={(open) => {
if (!open) {
clearOverride()
}
}}
>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Chart Color</div>
<div className="text-sm font-medium text-foreground">
{currentChartColor?.title}
</div>
</div>
{mounted && (
<div
style={
{
"--color":
currentChartColor?.cssVars?.dark?.[
currentChartColorIsBaseColor
? "muted-foreground"
: "primary"
],
} as React.CSSProperties
}
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 rounded-full bg-(--color) select-none md:right-2.5"
/>
)}
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
className="max-h-92"
onMouseLeave={clearOverride}
>
<PickerRadioGroup
value={currentChartColor?.name}
onValueChange={(value) => {
setParams({ chartColor: value as ChartColorName })
}}
onItemPreview={
isMobile
? undefined
: (value) =>
setOverride({ chartColor: value as ChartColorName })
}
>
<PickerGroup>
{availableChartColors
.filter((theme) =>
BASE_COLORS.find((baseColor) => baseColor.name === theme.name)
)
.map((theme) => (
<PickerRadioItem
key={theme.name}
value={theme.name}
closeOnClick={isMobile}
>
{theme.title}
</PickerRadioItem>
))}
</PickerGroup>
<PickerSeparator />
<PickerGroup>
{availableChartColors
.filter(
(theme) =>
!BASE_COLORS.find(
(baseColor) => baseColor.name === theme.name
)
)
.map((theme) => (
<PickerRadioItem
key={theme.name}
value={theme.name}
closeOnClick={isMobile}
>
{theme.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="chartColor"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/styles/base-nova/ui/button"
import { usePresetCode } from "@/app/(app)/(create)/hooks/use-design-system"
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
const presetCode = usePresetCode()
const [hasCopied, setHasCopied] = React.useState(false)
const label = hasCopied ? "Copied" : `--preset ${presetCode}`
React.useEffect(() => {
if (hasCopied) {
const timer = setTimeout(() => setHasCopied(false), 2000)
return () => clearTimeout(timer)
}
}, [hasCopied])
const handleCopy = React.useCallback(() => {
copyToClipboardWithMeta(`--preset ${presetCode}`, {
name: "copy_preset_command",
properties: {
preset: presetCode,
},
})
setHasCopied(true)
}, [presetCode])
return (
<Button
variant="outline"
onClick={handleCopy}
title={label}
className={cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)}
>
<span className="block min-w-0 truncate">{label}</span>
</Button>
)
}

View File

@@ -0,0 +1,72 @@
"use client"
import * as React from "react"
import { BASES } from "@/registry/config"
import { Button } from "@/registry/new-york-v4/ui/button"
import { getDocsPathForItem } from "@/app/(app)/(create)/lib/devtools"
import {
serializeDesignSystemSearchParams,
useDesignSystemSearchParams,
} from "@/app/(app)/(create)/lib/search-params"
export function CreateDevtools() {
const [params, setParams] = useDesignSystemSearchParams()
const previewUrl = React.useMemo(
() =>
serializeDesignSystemSearchParams(
`/preview/${params.base}/${params.item}`,
params
),
[params]
)
const docsUrl = React.useMemo(
() => getDocsPathForItem(params.base, params.item),
[params.base, params.item]
)
if (process.env.NODE_ENV === "production") {
return null
}
return (
<div className="dark absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 items-center gap-0.5 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
{BASES.map((base) => (
<Button
key={base.name}
variant="ghost"
size="sm"
data-active={params.base === base.name}
className="h-7 min-w-8 cursor-pointer rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
onClick={() => setParams({ base: base.name })}
>
{base.title}
</Button>
))}
<div className="mx-0.5 h-4 w-px bg-border/80" />
<Button
asChild
variant="ghost"
size="sm"
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<a href={previewUrl} target="_blank" rel="noreferrer">
Open in New Tab
</a>
</Button>
<div className="mx-0.5 h-4 w-px bg-border/80" />
<Button
asChild
variant="ghost"
size="sm"
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<a href={docsUrl} target="_blank" rel="noreferrer">
Open Docs
</a>
</Button>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
export function CreateSkeleton() {
return (
<div className="relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden section-soft [--customizer-width:--spacing(48)] [--gap:--spacing(4)] md:[--gap:--spacing(6)] 2xl:[--customizer-width:--spacing(56)]">
<div
data-slot="designer"
className="flex min-h-0 flex-1 flex-col gap-(--gap) p-(--gap) pt-[calc(var(--gap)*0.25)] md:flex-row-reverse"
>
<Skeleton className="flex-1 rounded-2xl" />
<Skeleton className="min-h-[151px] w-full self-start rounded-2xl md:h-full md:max-h-full md:min-h-0 md:w-(--customizer-width)" />
</div>
</div>
)
}

View File

@@ -0,0 +1,131 @@
"use client"
import * as React from "react"
import dynamic from "next/dynamic"
import { type RegistryItem } from "shadcn/schema"
import { useIsMobile } from "@/hooks/use-mobile"
import { getThemesForBaseColor, STYLES } from "@/registry/config"
import { Button } from "@/styles/base-nova/ui/button"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-nova/ui/card"
import { FieldGroup, FieldSeparator } from "@/styles/base-nova/ui/field"
import { MenuAccentPicker } from "@/app/(app)/(create)/components/accent-picker"
import { ActionMenu } from "@/app/(app)/(create)/components/action-menu"
import { BaseColorPicker } from "@/app/(app)/(create)/components/base-color-picker"
import { BasePicker } from "@/app/(app)/(create)/components/base-picker"
import { ChartColorPicker } from "@/app/(app)/(create)/components/chart-color-picker"
import { CopyPreset } from "@/app/(app)/(create)/components/copy-preset"
import { FontPicker } from "@/app/(app)/(create)/components/font-picker"
import { IconLibraryPicker } from "@/app/(app)/(create)/components/icon-library-picker"
import { MainMenu } from "@/app/(app)/(create)/components/main-menu"
import { MenuColorPicker } from "@/app/(app)/(create)/components/menu-picker"
import { OpenPreset } from "@/app/(app)/(create)/components/open-preset"
import { RadiusPicker } from "@/app/(app)/(create)/components/radius-picker"
import { RandomButton } from "@/app/(app)/(create)/components/random-button"
import { ResetDialog } from "@/app/(app)/(create)/components/reset-button"
import { StylePicker } from "@/app/(app)/(create)/components/style-picker"
import { ThemePicker } from "@/app/(app)/(create)/components/theme-picker"
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/(create)/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
// Only visible when user clicks "Create Project". Rendered client-only to
// avoid a useId hydration mismatch on the Base UI dialog trigger. The loading
// placeholder mirrors the trigger button exactly so there is no layout shift.
const ProjectForm = dynamic(
() =>
import("@/app/(app)/(create)/components/project-form").then(
(m) => m.ProjectForm
),
{
ssr: false,
loading: () => (
<Button disabled aria-hidden>
Get Code
</Button>
),
}
)
export function Customizer({
itemsByBase,
}: {
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
}) {
const [params] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const anchorRef = React.useRef<HTMLDivElement | null>(null)
const availableThemes = React.useMemo(
() => getThemesForBaseColor(params.baseColor),
[params.baseColor]
)
return (
<Card
className="dark top-24 right-12 isolate z-10 max-h-full min-h-0 w-full self-start rounded-2xl bg-card/90 backdrop-blur-xl md:w-(--customizer-width)"
ref={anchorRef}
size="sm"
>
<CardHeader className="hidden items-center justify-between gap-2 border-b group-data-reversed/layout:flex-row-reverse md:flex">
<MainMenu />
</CardHeader>
<CardContent className="no-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden md:overflow-y-auto">
<FieldGroup className="flex-row gap-2.5 py-px **:data-[slot=field-separator]:-mx-4 **:data-[slot=field-separator]:w-auto md:flex-col md:gap-3.25">
<StylePicker
styles={STYLES}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FieldSeparator className="hidden md:block" />
<BaseColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<ThemePicker
themes={availableThemes}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<ChartColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<FieldSeparator className="hidden md:block" />
<FontPicker
label="Heading"
param="fontHeading"
fonts={FONT_HEADING_OPTIONS}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FontPicker
label="Font"
param="font"
fonts={FONTS}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FieldSeparator className="hidden md:block" />
<IconLibraryPicker isMobile={isMobile} anchorRef={anchorRef} />
<RadiusPicker isMobile={isMobile} anchorRef={anchorRef} />
<FieldSeparator className="hidden md:block" />
<MenuColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<MenuAccentPicker isMobile={isMobile} anchorRef={anchorRef} />
{isMobile && <BasePicker isMobile={isMobile} anchorRef={anchorRef} />}
</FieldGroup>
</CardContent>
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
<OpenPreset
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
label={isMobile ? "Open" : "Open Preset"}
/>
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
<ActionMenu itemsByBase={itemsByBase} />
<ResetDialog />
</CardFooter>
<CardFooter className="-mt-3 hidden min-w-0 gap-2 md:flex md:flex-col md:**:[button,a]:w-full">
<ProjectForm />
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,323 @@
"use client"
import * as React from "react"
import {
buildRegistryTheme,
DEFAULT_CONFIG,
POINTER_CURSOR_SELECTOR,
type DesignSystemConfig,
} from "@/registry/config"
import { useIframeMessageListener } from "@/app/(app)/(create)/hooks/use-iframe-sync"
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(app)/(create)/lib/search-params"
const THEME_STYLE_ELEMENT_ID = "design-system-theme-vars"
const MANAGED_BODY_CLASS_PREFIXES = ["style-", "base-color-"] as const
const POINTER_CURSOR_CSS = `@layer base {
${POINTER_CURSOR_SELECTOR} {
cursor: pointer;
}
}
`
type RegistryThemeCssVars = NonNullable<
ReturnType<typeof buildRegistryTheme>["cssVars"]
>
function removeManagedBodyClasses(body: Element) {
for (const className of Array.from(body.classList)) {
if (
MANAGED_BODY_CLASS_PREFIXES.some((prefix) => className.startsWith(prefix))
) {
body.classList.remove(className)
}
}
}
function buildCssRule(selector: string, cssVars?: Record<string, string>) {
const declarations = Object.entries(cssVars ?? {})
.filter(([, value]) => Boolean(value))
.map(([key, value]) => ` --${key}: ${value};`)
.join("\n")
if (!declarations) {
return `${selector} {}\n`
}
return `${selector} {\n${declarations}\n}\n`
}
function buildThemeCssText(cssVars: RegistryThemeCssVars, pointer: boolean) {
return [
buildCssRule(":root", {
...(cssVars.theme ?? {}),
...(cssVars.light ?? {}),
}),
buildCssRule(".dark", cssVars.dark),
pointer ? POINTER_CURSOR_CSS : "",
]
.filter(Boolean)
.join("\n")
}
export function DesignSystemProvider({
children,
}: {
children: React.ReactNode
}) {
const [searchParams, setSearchParams] = useDesignSystemSearchParams({
shallow: true, // No need to go through the server…
history: "replace", // …or push updates into the iframe history.
})
const [isReady, setIsReady] = React.useState(false)
const {
style,
theme,
font,
fontHeading,
baseColor,
chartColor,
menuAccent,
menuColor,
pointer,
radius,
} = searchParams
const effectiveRadius = style === "lyra" ? "none" : radius
const selectedFont = React.useMemo(
() => FONTS.find((fontOption) => fontOption.value === font),
[font]
)
const selectedHeadingFont = React.useMemo(() => {
if (fontHeading === "inherit" || fontHeading === font) {
return selectedFont
}
return FONTS.find((fontOption) => fontOption.value === fontHeading)
}, [font, fontHeading, selectedFont])
const initialFontSansRef = React.useRef<string | null>(null)
const initialFontHeadingRef = React.useRef<string | null>(null)
React.useEffect(() => {
initialFontSansRef.current =
document.documentElement.style.getPropertyValue("--font-sans")
initialFontHeadingRef.current =
document.documentElement.style.getPropertyValue("--font-heading")
return () => {
removeManagedBodyClasses(document.body)
document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove()
if (initialFontSansRef.current) {
document.documentElement.style.setProperty(
"--font-sans",
initialFontSansRef.current
)
} else {
document.documentElement.style.removeProperty("--font-sans")
}
if (initialFontHeadingRef.current) {
document.documentElement.style.setProperty(
"--font-heading",
initialFontHeadingRef.current
)
} else {
document.documentElement.style.removeProperty("--font-heading")
}
}
}, [])
const handleDesignSystemMessage = React.useCallback(
(nextParams: DesignSystemSearchParams) => {
setSearchParams(nextParams)
},
[setSearchParams]
)
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
React.useEffect(() => {
if (style === "lyra" || (style === "sera" && radius !== "none")) {
setSearchParams({ radius: "none" })
return
}
if (style === "rhea" && radius === "large") {
setSearchParams({ radius: "default" })
}
}, [style, radius, setSearchParams])
// Use useLayoutEffect for synchronous style updates to prevent flash.
React.useLayoutEffect(() => {
if (!style || !theme || !font || !baseColor) {
return
}
const body = document.body
// Iterate over a snapshot so removals do not affect traversal.
removeManagedBodyClasses(body)
body.classList.add(`style-${style}`, `base-color-${baseColor}`)
// Update font.
// Always set --font-sans for the preview so the selected font is visible.
// The font type (sans/serif/mono) is metadata for the CLI updater.
if (selectedFont) {
document.documentElement.style.setProperty(
"--font-sans",
selectedFont.font.style.fontFamily
)
}
if (selectedHeadingFont) {
document.documentElement.style.setProperty(
"--font-heading",
selectedHeadingFont.font.style.fontFamily
)
}
setIsReady(true)
}, [
style,
theme,
font,
fontHeading,
baseColor,
selectedFont,
selectedHeadingFont,
])
const registryTheme = React.useMemo(() => {
if (!baseColor || !theme || !menuAccent || !effectiveRadius) {
return null
}
const config: DesignSystemConfig = {
...DEFAULT_CONFIG,
baseColor,
theme,
chartColor,
menuAccent,
radius: effectiveRadius,
}
return buildRegistryTheme(config)
}, [baseColor, theme, chartColor, menuAccent, effectiveRadius])
// Use useLayoutEffect for synchronous CSS var updates.
React.useLayoutEffect(() => {
if (!registryTheme || !registryTheme.cssVars) {
return
}
let styleElement = document.getElementById(
THEME_STYLE_ELEMENT_ID
) as HTMLStyleElement | null
if (!styleElement) {
styleElement = document.createElement("style")
styleElement.id = THEME_STYLE_ELEMENT_ID
document.head.appendChild(styleElement)
}
styleElement.textContent = buildThemeCssText(registryTheme.cssVars, pointer)
}, [registryTheme, pointer])
// Handle menu color inversion by adding/removing dark class to elements with cn-menu-target.
// useLayoutEffect to apply classes synchronously before paint, avoiding flash.
React.useLayoutEffect(() => {
if (!menuColor) {
return
}
const isInvertedMenu =
menuColor === "inverted" || menuColor === "inverted-translucent"
const isTranslucentMenu =
menuColor === "default-translucent" ||
menuColor === "inverted-translucent"
let frameId = 0
const updateMenuElements = () => {
const allElements = document.querySelectorAll<HTMLElement>(
".cn-menu-target, [data-menu-translucent]"
)
if (allElements.length === 0) {
return
}
// Disable transitions while toggling classes.
allElements.forEach((element) => {
element.style.transition = "none"
})
allElements.forEach((element) => {
if (element.classList.contains("cn-menu-target")) {
if (isInvertedMenu) {
element.classList.add("dark")
} else {
element.classList.remove("dark")
}
}
// When translucent is enabled, move from data-attr to class so styles apply.
// When disabled, move back to a data-attr so the element stays queryable
// for future toggles without losing its identity as a menu element.
if (isTranslucentMenu) {
element.classList.add("cn-menu-translucent")
element.removeAttribute("data-menu-translucent")
} else if (element.classList.contains("cn-menu-translucent")) {
element.classList.remove("cn-menu-translucent")
element.setAttribute("data-menu-translucent", "")
}
})
// Force a reflow, then re-enable transitions.
void document.body.offsetHeight
allElements.forEach((element) => {
element.style.transition = ""
})
}
const scheduleMenuUpdate = () => {
if (frameId) {
return
}
frameId = window.requestAnimationFrame(() => {
frameId = 0
updateMenuElements()
})
}
// Update existing menu elements.
updateMenuElements()
// Watch for new menu elements being added to the DOM.
const observer = new MutationObserver(() => {
scheduleMenuUpdate()
})
observer.observe(document.body, {
childList: true,
subtree: true,
})
return () => {
observer.disconnect()
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [menuColor])
if (!isReady) {
return null
}
return <>{children}</>
}

View File

@@ -0,0 +1,175 @@
"use client"
import * as React from "react"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerLabel,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { usePreviewOverride } from "@/app/(app)/(create)/components/preview-override"
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(app)/(create)/lib/search-params"
type FontPickerOption = {
name: string
value: string
type: string
font: {
style: {
fontFamily: string
}
} | null
}
export function FontPicker({
label,
param,
fonts,
isMobile,
anchorRef,
}: {
label: string
param: "font" | "fontHeading"
fonts: readonly FontPickerOption[]
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const { setOverride, clearOverride } = usePreviewOverride()
const currentValue = param === "font" ? params.font : params.fontHeading
const handleFontChange = React.useCallback(
(value: string) => {
setParams({
[param]: value,
} as Partial<DesignSystemSearchParams>)
},
[param, setParams]
)
const currentFont = React.useMemo(
() => fonts.find((font) => font.value === currentValue),
[fonts, currentValue]
)
const currentBodyFont = React.useMemo(
() => FONTS.find((font) => font.value === params.font),
[params.font]
)
const inheritsBodyFont = param === "fontHeading" && currentValue === "inherit"
const displayFontName = inheritsBodyFont
? currentBodyFont?.name
: currentFont?.name
const inheritFontLabel = currentBodyFont ? currentBodyFont.name : "Body font"
const groupedFonts = React.useMemo(() => {
const pickerFonts =
param === "fontHeading"
? fonts.filter((font) => font.value !== "inherit")
: fonts
const groups = new Map<string, FontPickerOption[]>()
for (const font of pickerFonts) {
const existing = groups.get(font.type)
if (existing) {
existing.push(font)
continue
}
groups.set(font.type, [font])
}
return Array.from(groups.entries()).map(([type, items]) => ({
type,
label: `${type.charAt(0).toUpperCase()}${type.slice(1)}`,
items,
}))
}, [fonts, param])
return (
<div className="group/picker relative">
<Picker
onOpenChange={(open) => {
if (!open) {
clearOverride()
}
}}
>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="line-clamp-1 max-w-[80%] truncate text-sm font-medium text-foreground">
{displayFontName}
</div>
</div>
<div
className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5"
style={{
fontFamily:
currentFont?.font?.style.fontFamily ??
currentBodyFont?.font.style.fontFamily,
}}
>
Aa
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
className="max-h-96"
onMouseLeave={clearOverride}
>
<PickerRadioGroup
value={currentValue}
onValueChange={handleFontChange}
onItemPreview={
isMobile
? undefined
: (value) =>
setOverride({
[param]: value,
} as Partial<DesignSystemSearchParams>)
}
>
{param === "fontHeading" ? (
<>
<PickerGroup>
<PickerRadioItem value="inherit" closeOnClick={isMobile}>
{inheritFontLabel}
</PickerRadioItem>
</PickerGroup>
<PickerSeparator />
</>
) : null}
{groupedFonts.map((group) => (
<PickerGroup key={group.type}>
<PickerLabel>{group.label}</PickerLabel>
{group.items.map((font) => (
<PickerRadioItem
key={font.value}
value={font.value}
closeOnClick={isMobile}
>
{font.name}
</PickerRadioItem>
))}
</PickerGroup>
))}
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param={param}
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -0,0 +1,78 @@
"use client"
import Script from "next/script"
import { Redo02Icon, Undo02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { Button } from "@/styles/base-nova/ui/button"
import { useHistory } from "@/app/(app)/(create)/hooks/use-history"
export const UNDO_FORWARD_TYPE = "undo-forward"
export const REDO_FORWARD_TYPE = "redo-forward"
export function HistoryButtons() {
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
title="Undo"
disabled={!canGoBack}
onClick={goBack}
>
<HugeiconsIcon icon={Undo02Icon} />
<span className="sr-only">Undo</span>
</Button>
<Button
variant="ghost"
size="icon"
title="Redo"
disabled={!canGoForward}
onClick={goForward}
>
<HugeiconsIcon icon={Redo02Icon} />
<span className="sr-only">Redo</span>
</Button>
</div>
)
}
export function HistoryScript() {
return (
<Script
id="history-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
document.addEventListener('keydown', function(e) {
if (!e.metaKey && !e.ctrlKey) return;
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
var key = e.key.toLowerCase();
if ((key === 'z' && e.shiftKey) || (key === 'y' && e.ctrlKey)) {
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: '${REDO_FORWARD_TYPE}' }, '*');
}
} else if (key === 'z') {
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: '${UNDO_FORWARD_TYPE}' }, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -0,0 +1,186 @@
"use client"
import * as React from "react"
import { iconLibraries, type IconLibraryName } from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { usePreviewOverride } from "@/app/(app)/(create)/components/preview-override"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
const logos = {
lucide: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
d="M14 12a4 4 0 0 0-8 0 8 8 0 1 0 16 0 11.97 11.97 0 0 0-4-8.944"
/>
<path
stroke="currentColor"
d="M10 12a4 4 0 0 0 8 0 8 8 0 1 0-16 0 11.97 11.97 0 0 0 4.063 9"
/>
</svg>
),
tabler: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="none"
viewBox="0 0 32 32"
>
<path
fill="currentColor"
d="M31.288 7.107A8.83 8.83 0 0 0 24.893.712a55.9 55.9 0 0 0-17.786 0A8.83 8.83 0 0 0 .712 7.107a55.9 55.9 0 0 0 0 17.786 8.83 8.83 0 0 0 6.395 6.395c5.895.95 11.89.95 17.786 0a8.83 8.83 0 0 0 6.395-6.395c.95-5.895.95-11.89 0-17.786"
/>
<path
fill="#fff"
d="m17.884 9.076 1.5-2.488 6.97 6.977-2.492 1.494zm-7.96 3.127 7.814-.909 3.91 3.66-.974 7.287-9.582 2.159a3.06 3.06 0 0 1-2.17-.329l5.244-4.897c.91.407 2.003.142 2.587-.626.584-.77.488-1.818-.226-2.484s-1.84-.755-2.664-.21c-.823.543-1.107 1.562-.67 2.412l-5.245 4.89a2.53 2.53 0 0 1-.339-2.017z"
/>
</svg>
),
hugeicons: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="128"
height="128"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M2 9.5H22" stroke="currentColor"></path>
<path
d="M20.5 9.5H3.5L4.23353 15.3682C4.59849 18.2879 4.78097 19.7477 5.77343 20.6239C6.76589 21.5 8.23708 21.5 11.1795 21.5H12.8205C15.7629 21.5 17.2341 21.5 18.2266 20.6239C19.219 19.7477 19.4015 18.2879 19.7665 15.3682L20.5 9.5Z"
stroke="currentColor"
></path>
<path
d="M5 9C5 5.41015 8.13401 2.5 12 2.5C15.866 2.5 19 5.41015 19 9"
stroke="currentColor"
></path>
</svg>
),
phosphor: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
width="32"
height="32"
>
<path fill="none" d="M0 0h32v32H0z" />
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5h9v16H9zm9 16v9a9 9 0 0 1-9-9M9 5l9 16m0 0h1a8 8 0 0 0 0-16h-1"
/>
</svg>
),
remixicon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
>
<path d="M12 2C17.5228 2 22 6.47715 22 12C22 15.3137 19.3137 18 16 18C12.6863 18 10 15.3137 10 12C10 11.4477 9.55228 11 9 11C8.44772 11 8 11.4477 8 12C8 16.4183 11.5817 20 16 20C16.8708 20 17.7084 19.8588 18.4932 19.6016C16.7458 21.0956 14.4792 22 12 22C6.6689 22 2.3127 17.8283 2.0166 12.5713C2.23647 9.45772 4.83048 7 8 7C11.3137 7 14 9.68629 14 13C14 13.5523 14.4477 14 15 14C15.5523 14 16 13.5523 16 13C16 8.58172 12.4183 5 8 5C6.50513 5 5.1062 5.41032 3.90918 6.12402C5.72712 3.62515 8.67334 2 12 2Z" />
</svg>
),
}
export function IconLibraryPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const { setOverride, clearOverride } = usePreviewOverride()
const currentIconLibrary = React.useMemo(
() => iconLibraries[params.iconLibrary as keyof typeof iconLibraries],
[params.iconLibrary]
)
return (
<div className="group/picker relative">
<Picker
onOpenChange={(open) => {
if (!open) {
clearOverride()
}
}}
>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Icon Library</div>
<div className="text-sm font-medium text-foreground">
{currentIconLibrary?.title}
</div>
</div>
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5 *:[svg]:text-foreground!">
{logos[currentIconLibrary?.name as keyof typeof logos]}
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
onMouseLeave={clearOverride}
>
<PickerRadioGroup
value={currentIconLibrary?.name}
onValueChange={(value) => {
setParams({ iconLibrary: value as IconLibraryName })
}}
onItemPreview={
isMobile
? undefined
: (value) =>
setOverride({ iconLibrary: value as IconLibraryName })
}
>
<PickerGroup>
{Object.values(iconLibraries).map((iconLibrary) => (
<PickerRadioItem
key={iconLibrary.name}
value={iconLibrary.name}
closeOnClick={isMobile}
>
{iconLibrary.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="iconLibrary"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -0,0 +1,75 @@
"use client"
import { lazy, Suspense } from "react"
import { SquareIcon } from "lucide-react"
import type { IconLibraryName } from "shadcn/icons"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
const IconLucide = lazy(() =>
import("@/registry/icons/icon-lucide").then((mod) => ({
default: mod.IconLucide,
}))
)
const IconTabler = lazy(() =>
import("@/registry/icons/icon-tabler").then((mod) => ({
default: mod.IconTabler,
}))
)
const IconHugeicons = lazy(() =>
import("@/registry/icons/icon-hugeicons").then((mod) => ({
default: mod.IconHugeicons,
}))
)
const IconPhosphor = lazy(() =>
import("@/registry/icons/icon-phosphor").then((mod) => ({
default: mod.IconPhosphor,
}))
)
const IconRemixicon = lazy(() =>
import("@/registry/icons/icon-remixicon").then((mod) => ({
default: mod.IconRemixicon,
}))
)
// Preload all icon renderer modules so switching libraries is instant.
// These warm the browser module cache; React.lazy resolves immediately
// for modules that are already loaded.
void import("@/registry/icons/icon-lucide")
void import("@/registry/icons/icon-tabler")
void import("@/registry/icons/icon-hugeicons")
void import("@/registry/icons/icon-phosphor")
void import("@/registry/icons/icon-remixicon")
export function IconPlaceholder({
...props
}: {
[K in IconLibraryName]: string
} & React.ComponentProps<"svg">) {
const [{ iconLibrary }] = useDesignSystemSearchParams()
const iconName = props[iconLibrary]
if (!iconName) {
return null
}
return (
<Suspense fallback={<SquareIcon {...props} />}>
{iconLibrary === "lucide" && <IconLucide name={iconName} {...props} />}
{iconLibrary === "tabler" && <IconTabler name={iconName} {...props} />}
{iconLibrary === "hugeicons" && (
<IconHugeicons name={iconName} {...props} />
)}
{iconLibrary === "phosphor" && (
<IconPhosphor name={iconName} {...props} />
)}
{iconLibrary === "remixicon" && (
<IconRemixicon name={iconName} {...props} />
)}
</Suspense>
)
}

View File

@@ -0,0 +1,108 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { cn } from "cn"
import { ChevronRightIcon } from "lucide-react"
import { type RegistryItem } from "shadcn/schema"
import { type Base } from "@/registry/bases"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/styles/base-nova/ui/collapsible"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/styles/base-nova/ui/sidebar"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
import { groupItemsByType } from "@/app/(app)/(create)/lib/utils"
const cachedGroupedItems = React.cache(
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {
return groupItemsByType(items)
}
)
export function ItemExplorer({
base,
items,
}: {
base: Base["name"]
items: Pick<RegistryItem, "name" | "title" | "type">[]
}) {
const [params, setParams] = useDesignSystemSearchParams()
const groupedItems = React.useMemo(() => cachedGroupedItems(items), [items])
const currentItem = React.useMemo(
() => items.find((item) => item.name === params.item) ?? null,
[items, params.item]
)
return (
<Sidebar
className="sticky z-30 hidden h-full overscroll-none bg-transparent xl:flex"
collapsible="none"
>
<SidebarContent className="-mx-1 no-scrollbar overflow-x-hidden">
{groupedItems.map((group) => (
<Collapsible
key={group.type}
defaultOpen
className="group/collapsible"
>
<SidebarGroup className="px-1 py-0">
<CollapsibleTrigger className="flex w-full items-center gap-1 py-1.5 text-[0.8rem] font-medium [&[data-state=open]>svg]:rotate-90">
<ChevronRightIcon className="size-3.5 text-muted-foreground transition-transform" />
<span>{group.title}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu className="relative ml-1.5 border-l border-border/50 pl-2">
{group.items.map((item, index) => (
<SidebarMenuItem key={item.name} className="relative">
<div
className={cn(
"absolute top-1/2 -left-2 h-px w-2 border-t border-border/50",
index === group.items.length - 1 && "bg-sidebar"
)}
/>
{index === group.items.length - 1 && (
<div className="absolute top-1/2 -bottom-1 -left-2.5 w-1 bg-sidebar" />
)}
<SidebarMenuButton
onClick={() => setParams({ item: item.name })}
className="relative h-[26px] w-fit cursor-pointer overflow-visible border border-transparent text-[0.8rem] font-normal after:absolute after:inset-x-0 after:-inset-y-1 after:z-0 after:rounded-md data-[active=true]:border-accent data-[active=true]:bg-accent 3xl:fixed:w-full 3xl:fixed:max-w-48"
data-active={item.name === currentItem?.name}
isActive={item.name === currentItem?.name}
>
{item.title}
<span className="absolute inset-0 flex w-(--sidebar-width) bg-transparent" />
</SidebarMenuButton>
<Link
href={`/preview/${base}/${item.name}`}
prefetch
className="sr-only"
tabIndex={-1}
>
{item.title}
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
))}
</SidebarContent>
</Sidebar>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import {
SquareLock01Icon,
SquareUnlock01Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "cn"
import {
useLocks,
type LockableParam,
} from "@/app/(app)/(create)/hooks/use-locks"
export function LockButton({
param,
className,
}: {
param: LockableParam
className?: string
}) {
const { isLocked, toggleLock } = useLocks()
const locked = isLocked(param)
return (
<button
type="button"
title={locked ? "Unlock" : "Lock"}
aria-label={locked ? "Unlock" : "Lock"}
onClick={() => toggleLock(param)}
data-locked={locked}
className={cn(
"flex size-4 cursor-pointer items-center justify-center rounded opacity-0 ring-foreground/60 transition-opacity outline-none group-focus-within/picker:opacity-100 group-hover/picker:opacity-100 focus:opacity-100 focus-visible:ring-1 data-[locked=true]:opacity-100 pointer-coarse:hidden",
className
)}
>
<HugeiconsIcon
icon={locked ? SquareLock01Icon : SquareUnlock01Icon}
strokeWidth={2}
className="size-5 text-foreground"
/>
</button>
)
}

View File

@@ -0,0 +1,88 @@
"use client"
import * as React from "react"
import { Menu09Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "cn"
import { type Button } from "@/styles/base-nova/ui/button"
import {
Picker,
PickerContent,
PickerGroup,
PickerItem,
PickerSeparator,
PickerShortcut,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { useActionMenuTrigger } from "@/app/(app)/(create)/hooks/use-action-menu"
import { useHistory } from "@/app/(app)/(create)/hooks/use-history"
import { useOpenPresetTrigger } from "@/app/(app)/(create)/hooks/use-open-preset"
import { useRandom } from "@/app/(app)/(create)/hooks/use-random"
import { useReset } from "@/app/(app)/(create)/hooks/use-reset"
import { useThemeToggle } from "@/app/(app)/(create)/hooks/use-theme-toggle"
const APPLE_PLATFORM_REGEX = /Mac|iPhone|iPad|iPod/
export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
const [isMac, setIsMac] = React.useState(false)
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
const { openActionMenu } = useActionMenuTrigger()
const { openPreset } = useOpenPresetTrigger()
const { randomize } = useRandom()
const { toggleTheme } = useThemeToggle()
const { setShowResetDialog } = useReset()
React.useEffect(() => {
const platform = navigator.platform
const userAgent = navigator.userAgent
setIsMac(APPLE_PLATFORM_REGEX.test(platform || userAgent))
}, [])
return (
<React.Fragment>
<Picker>
<PickerTrigger
className={cn(
"flex items-center justify-between gap-2 rounded-lg px-1.75 ring-1 ring-foreground/10 focus-visible:ring-1",
className
)}
>
<span className="font-medium">Menu</span>
<HugeiconsIcon icon={Menu09Icon} strokeWidth={2} className="size-5" />
</PickerTrigger>
<PickerContent side="right" align="start" alignOffset={-8}>
<PickerGroup>
<PickerItem onClick={openActionMenu}>
Navigate...
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
</PickerItem>
<PickerItem onClick={openPreset}>
Open Preset... <PickerShortcut>O</PickerShortcut>
</PickerItem>
<PickerItem onClick={randomize}>
Shuffle <PickerShortcut>R</PickerShortcut>
</PickerItem>
<PickerItem onClick={toggleTheme}>
Light/Dark <PickerShortcut>D</PickerShortcut>
</PickerItem>
</PickerGroup>
<PickerSeparator />
<PickerGroup>
<PickerItem onClick={goBack} disabled={!canGoBack}>
Undo <PickerShortcut>{isMac ? "⌘Z" : "Ctrl+Z"}</PickerShortcut>
</PickerItem>
<PickerItem onClick={goForward} disabled={!canGoForward}>
Redo{" "}
<PickerShortcut>{isMac ? "⇧⌘Z" : "Ctrl+Shift+Z"}</PickerShortcut>
</PickerItem>
<PickerSeparator />
<PickerItem onClick={() => setShowResetDialog(true)}>
Reset <PickerShortcut>⇧R</PickerShortcut>
</PickerItem>
</PickerGroup>
</PickerContent>
</Picker>
</React.Fragment>
)
}

View File

@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import { Menu02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { useTheme } from "next-themes"
import { useMounted } from "@/hooks/use-mounted"
import { type MenuColorValue } from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerLabel,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { usePreviewOverride } from "@/app/(app)/(create)/components/preview-override"
import {
isTranslucentMenuColor,
useDesignSystemSearchParams,
} from "@/app/(app)/(create)/lib/search-params"
type ColorChoice = "default" | "inverted"
type SurfaceChoice = "solid" | "translucent"
function getMenuColorValue(
color: ColorChoice,
translucent: boolean
): MenuColorValue {
if (color === "default") {
return translucent ? "default-translucent" : "default"
}
return translucent ? "inverted-translucent" : "inverted"
}
const MENU_OPTIONS: { value: MenuColorValue; label: string }[] = [
{ value: "default", label: "Default / Solid" },
{ value: "default-translucent", label: "Default / Translucent" },
{ value: "inverted", label: "Inverted / Solid" },
{ value: "inverted-translucent", label: "Inverted / Translucent" },
]
export function MenuColorPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const { setOverride, clearOverride } = usePreviewOverride()
const { resolvedTheme } = useTheme()
const mounted = useMounted()
const lastSolidMenuAccentRef = React.useRef(params.menuAccent)
const isDark = mounted && resolvedTheme === "dark"
const currentMenu = MENU_OPTIONS.find(
(menu) => menu.value === params.menuColor
)
const colorChoice: ColorChoice =
params.menuColor === "inverted" ||
params.menuColor === "inverted-translucent"
? "inverted"
: "default"
const surfaceChoice: SurfaceChoice =
params.menuColor === "default-translucent" ||
params.menuColor === "inverted-translucent"
? "translucent"
: "solid"
React.useEffect(() => {
if (surfaceChoice === "solid") {
lastSolidMenuAccentRef.current = params.menuAccent
}
}, [params.menuAccent, surfaceChoice])
const setColor = (color: ColorChoice) => {
const nextMenuColor = getMenuColorValue(
color,
surfaceChoice === "translucent"
)
setParams({
menuColor: nextMenuColor,
...(isTranslucentMenuColor(nextMenuColor) && { menuAccent: "subtle" }),
})
}
const setSurface = (choice: SurfaceChoice) => {
const isTranslucent = choice === "translucent"
const nextMenuColor = getMenuColorValue(colorChoice, isTranslucent)
setParams({
menuColor: nextMenuColor,
menuAccent: isTranslucent ? "subtle" : lastSolidMenuAccentRef.current,
})
}
// Hover previews mirror setColor/setSurface, including the menuAccent
// coupling, so the previewed state matches what a click would commit.
const previewColor = (color: ColorChoice) => {
const nextMenuColor = getMenuColorValue(
color,
surfaceChoice === "translucent"
)
setOverride({
menuColor: nextMenuColor,
...(isTranslucentMenuColor(nextMenuColor) && {
menuAccent: "subtle" as const,
}),
})
}
const previewSurface = (choice: SurfaceChoice) => {
const isTranslucent = choice === "translucent"
setOverride({
menuColor: getMenuColorValue(colorChoice, isTranslucent),
menuAccent: isTranslucent ? "subtle" : lastSolidMenuAccentRef.current,
})
}
return (
<div className="group/picker relative">
<Picker
onOpenChange={(open) => {
if (!open) {
clearOverride()
}
}}
>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Menu</div>
<div className="line-clamp-1 max-w-[80%] truncate text-sm font-medium text-foreground">
{currentMenu?.label}
</div>
</div>
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5">
<HugeiconsIcon
icon={Menu02Icon}
strokeWidth={2}
className="size-4"
/>
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
onMouseLeave={clearOverride}
>
<PickerGroup>
<PickerLabel>Color</PickerLabel>
<PickerRadioGroup
value={colorChoice}
onValueChange={(value) => {
setColor(value as ColorChoice)
}}
onItemPreview={
isMobile
? undefined
: (value) => previewColor(value as ColorChoice)
}
>
<PickerRadioItem value="default" closeOnClick={isMobile}>
Default
</PickerRadioItem>
<PickerRadioItem
value="inverted"
closeOnClick={isMobile}
disabled={isDark}
>
Inverted
</PickerRadioItem>
</PickerRadioGroup>
</PickerGroup>
<PickerSeparator />
<PickerGroup>
<PickerLabel>Appearance</PickerLabel>
<PickerRadioGroup
value={surfaceChoice}
onValueChange={(value) => {
setSurface(value as SurfaceChoice)
}}
onItemPreview={
isMobile
? undefined
: (value) => previewSurface(value as SurfaceChoice)
}
>
<PickerRadioItem value="solid" closeOnClick={isMobile}>
Solid
</PickerRadioItem>
<PickerRadioItem value="translucent" closeOnClick={isMobile}>
Translucent
</PickerRadioItem>
</PickerRadioGroup>
</PickerGroup>
</PickerContent>
</Picker>
<LockButton
param="menuColor"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -0,0 +1,87 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { cn } from "cn"
import { Button } from "@/styles/base-nova/ui/button"
import { useThemeToggle } from "@/app/(app)/(create)/hooks/use-theme-toggle"
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
export function ModeSwitcher({
variant = "ghost",
className,
}: {
variant?: React.ComponentProps<typeof Button>["variant"]
className?: React.ComponentProps<typeof Button>["className"]
}) {
const { toggleTheme } = useThemeToggle()
return (
<Button
variant={variant}
size="icon"
className={cn("group/toggle extend-touch-target", className)}
onClick={toggleTheme}
id="mode-switcher-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4.5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 3l0 18" />
<path d="M12 9l4.65 -4.65" />
<path d="M12 14.3l7.37 -7.37" />
<path d="M12 19.6l8.85 -8.85" />
</svg>
<span className="sr-only">Toggle theme</span>
</Button>
)
}
export function DarkModeScript() {
return (
<Script
id="dark-mode-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward D key
document.addEventListener('keydown', function(e) {
if ((e.key === 'd' || e.key === 'D') && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${DARK_MODE_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -0,0 +1,203 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { cn } from "cn"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui/drawer"
import { Field, FieldContent, FieldLabel } from "@/styles/base-nova/ui/field"
import { Input } from "@/styles/base-nova/ui/input"
import {
OPEN_PRESET_FORWARD_TYPE,
useOpenPreset,
} from "@/app/(app)/(create)/hooks/use-open-preset"
import { parsePresetInput } from "@/app/(app)/(create)/lib/parse-preset-input"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
const PRESET_EXAMPLE = "b2D0wqNxT"
const PRESET_TITLE = "Open Preset"
const PRESET_DESCRIPTION = "Paste a preset code to load a saved configuration."
export function OpenPreset({
className,
label = "Open Preset",
}: React.ComponentProps<typeof Button> & {
label?: string
}) {
const [input, setInput] = React.useState("")
const [, setParams] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const { open, setOpen } = useOpenPreset()
const nextPreset = React.useMemo(() => parsePresetInput(input), [input])
const isInvalid = input.trim().length > 0 && nextPreset === null
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setInput("")
}
},
[setOpen]
)
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!nextPreset) {
return
}
setParams({ preset: nextPreset })
handleOpenChange(false)
},
[handleOpenChange, nextPreset, setParams]
)
const triggerClassName = cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)
const desktopTrigger = (
<Button variant="outline" className={triggerClassName} />
)
const fields = (
<Field data-invalid={isInvalid || undefined}>
<FieldLabel htmlFor="preset-code" className="sr-only">
Preset code
</FieldLabel>
<FieldContent>
<Input
id="preset-code"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={`${PRESET_EXAMPLE} or --preset ${PRESET_EXAMPLE}`}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
aria-invalid={isInvalid}
className="h-10 md:h-8"
/>
</FieldContent>
</Field>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger
render={<Button variant="outline" className={triggerClassName} />}
>
{label}
</DrawerTrigger>
<DrawerContent className="dark rounded-t-2xl!">
<DrawerHeader>
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
<DrawerDescription>{PRESET_DESCRIPTION}</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit}>
<div className="px-4 py-2">{fields}</div>
<DrawerFooter>
<Button type="submit" className="h-10" disabled={!nextPreset}>
Open
</Button>
<DrawerClose
render={
<Button variant="outline" type="button" className="h-10" />
}
>
Cancel
</DrawerClose>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
<DialogContent className="dark">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{PRESET_TITLE}</DialogTitle>
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
</DialogHeader>
<div className="py-4">{fields}</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>
Cancel
</DialogClose>
<Button type="submit" disabled={!nextPreset}>
Open
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function OpenPresetScript() {
return (
<Script
id="open-preset-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward O key.
document.addEventListener('keydown', function(e) {
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${OPEN_PRESET_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -0,0 +1,324 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "cn"
import { IconPlaceholder } from "@/app/(app)/(create)/components/icon-placeholder"
function Picker({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function PickerPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function PickerTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
return (
<MenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
className={cn(
"relative w-36 shrink-0 touch-manipulation rounded-xl p-3 ring-1 ring-foreground/10 select-none hover:bg-muted focus-visible:ring-foreground/50 focus-visible:outline-none disabled:opacity-50 data-popup-open:bg-muted md:w-full md:rounded-lg md:px-2.5 md:py-2",
className
)}
{...props}
/>
)
}
function PickerContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 20,
anchor,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "anchor"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
anchor={anchor}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"cn-menu-target z-50 no-scrollbar max-h-(--available-height) w-[calc(var(--available-width)-(--spacing(6)))] min-w-32 origin-(--transform-origin) translate-y-2 overflow-x-hidden overflow-y-auto rounded-xl border-0 bg-neutral-950/80 p-1.5 text-neutral-100 ring-1 ring-neutral-950/80 backdrop-blur-xl outline-none md:w-52 dark:bg-neutral-800/90 dark:ring-neutral-700/50 data-closed:overflow-hidden",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
<div className="absolute inset-y-0 right-0 left-62 z-40 bg-transparent" />
</MenuPrimitive.Portal>
)
}
function PickerGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function PickerLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium text-neutral-400 data-inset:pl-8",
className
)}
{...props}
/>
)
}
function PickerItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium outline-hidden select-none **:text-neutral-100 focus:bg-neutral-600 focus:text-neutral-100 focus:**:text-neutral-100 data-inset:pl-8 dark:focus:bg-neutral-700/80 pointer-coarse:gap-3 pointer-coarse:py-2.5 pointer-coarse:pl-3 pointer-coarse:text-base data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function PickerSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function PickerSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent/95 focus:text-accent-foreground focus:ring-1 focus:ring-foreground/20 not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-open:bg-accent/95 data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<IconPlaceholder
lucide="ChevronRightIcon"
tabler="IconChevronRight"
hugeicons="ArrowRight01Icon"
phosphor="CaretRightIcon"
remixicon="RiArrowRightSLine"
className="ml-auto"
/>
</MenuPrimitive.SubmenuTrigger>
)
}
function PickerSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof PickerContent>) {
return (
<PickerContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-md bg-popover/90 p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 backdrop-blur-xs duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function PickerCheckboxItem({
className,
children,
checked,
...props
}: MenuPrimitive.CheckboxItem.Props) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent/95 focus:text-accent-foreground focus:ring-1 focus:ring-foreground/20 focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
<MenuPrimitive.CheckboxItemIndicator>
<IconPlaceholder
lucide="CheckIcon"
tabler="IconCheck"
hugeicons="Tick02Icon"
phosphor="CheckIcon"
remixicon="RiCheckLine"
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
// Lets pickers preview an item's value on hover/highlight by declaring a
// single onItemPreview on the group instead of wiring every radio item.
const PickerPreviewContext = React.createContext<
((value: string) => void) | null
>(null)
function PickerRadioGroup({
onItemPreview,
...props
}: MenuPrimitive.RadioGroup.Props & {
onItemPreview?: (value: string) => void
}) {
return (
<PickerPreviewContext.Provider value={onItemPreview ?? null}>
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
</PickerPreviewContext.Provider>
)
}
function PickerRadioItem({
className,
children,
value,
onMouseMove,
onFocus,
...props
}: MenuPrimitive.RadioItem.Props) {
const onItemPreview = React.useContext(PickerPreviewContext)
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
value={value}
// Previews apply when the pointer settles: every mousemove re-arms the
// provider's trailing timer, so nothing applies while the cursor is in
// motion. Base UI moves DOM focus to the highlighted item, so onFocus
// covers keyboard (arrow key) browsing.
onMouseMove={(event) => {
onMouseMove?.(event)
onItemPreview?.(value as string)
}}
onFocus={(event) => {
onFocus?.(event)
onItemPreview?.(value as string)
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 text-sm font-medium outline-hidden select-none **:text-neutral-100 focus:bg-neutral-600 focus:text-neutral-100 focus:**:text-neutral-100 data-inset:pl-8 dark:focus:bg-neutral-700/80 pointer-coarse:gap-3 pointer-coarse:py-2.5 pointer-coarse:pl-3 pointer-coarse:text-base data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<IconPlaceholder
lucide="CheckIcon"
tabler="IconCheck"
hugeicons="Tick02Icon"
phosphor="CheckIcon"
remixicon="RiCheckLine"
className="size-4 pointer-coarse:size-5"
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function PickerSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn(
"-mx-1.5 my-1.5 h-px bg-neutral-600 dark:bg-neutral-700",
className
)}
{...props}
/>
)
}
function PickerShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-neutral-400! group-focus/dropdown-menu-item:text-neutral-100",
className
)}
{...props}
/>
)
}
export {
Picker,
PickerPortal,
PickerTrigger,
PickerContent,
PickerGroup,
PickerLabel,
PickerItem,
PickerCheckboxItem,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerShortcut,
PickerSub,
PickerSubTrigger,
PickerSubContent,
}

View File

@@ -0,0 +1,38 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { generateRandomPreset, isPresetCode } from "shadcn/preset"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function PresetHandler() {
const router = useRouter()
const [params, setParams] = useDesignSystemSearchParams()
const hasConverted = React.useRef(false)
React.useEffect(() => {
if (params.preset === "random") {
router.replace(`/create?preset=${generateRandomPreset()}`)
}
}, [params.preset, router])
React.useEffect(() => {
if (hasConverted.current) {
return
}
hasConverted.current = true
if (!params.preset || params.preset === "random") {
return
}
if (isPresetCode(params.preset)) {
return
}
setParams({ base: params.base })
}, [params.preset, params.base, setParams])
return null
}

View File

@@ -0,0 +1,125 @@
"use client"
import * as React from "react"
import { STYLES, type Preset } from "@/registry/config"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function PresetPicker({
presets,
isMobile,
anchorRef,
}: {
presets: readonly Preset[]
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentPreset = React.useMemo(() => {
return presets.find(
(preset) =>
preset.base === params.base &&
preset.style === params.style &&
preset.baseColor === params.baseColor &&
preset.theme === params.theme &&
preset.chartColor === params.chartColor &&
preset.iconLibrary === params.iconLibrary &&
preset.font === params.font &&
preset.fontHeading === params.fontHeading &&
preset.menuAccent === params.menuAccent &&
preset.menuColor === params.menuColor &&
preset.radius === params.radius
)
}, [
presets,
params.base,
params.style,
params.baseColor,
params.theme,
params.chartColor,
params.iconLibrary,
params.font,
params.fontHeading,
params.menuAccent,
params.menuColor,
params.radius,
])
// Filter presets for current base only
const currentBasePresets = React.useMemo(() => {
return presets.filter((preset) => preset.base === params.base)
}, [presets, params.base])
const handlePresetChange = (value: string) => {
const preset = presets.find((p) => p.title === value)
if (!preset) {
return
}
// Update all params including base.
setParams({
base: preset.base,
style: preset.style,
baseColor: preset.baseColor,
theme: preset.theme,
chartColor: preset.chartColor,
iconLibrary: preset.iconLibrary,
font: preset.font,
fontHeading: preset.fontHeading,
menuAccent: preset.menuAccent,
menuColor: preset.menuColor,
radius: preset.radius,
custom: false,
})
}
return (
<Picker>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Preset</div>
<div className="line-clamp-1 text-sm font-medium text-foreground">
{currentPreset?.description ?? "Custom"}
</div>
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
className="md:w-72"
>
<PickerRadioGroup
value={currentPreset?.title ?? ""}
onValueChange={handlePresetChange}
>
<PickerGroup>
{currentBasePresets.map((preset) => {
const style = STYLES.find((s) => s.name === preset.style)
return (
<PickerRadioItem
key={preset.title}
value={preset.title}
closeOnClick={isMobile}
>
<div className="flex items-center gap-2">
{preset.description}
</div>
</PickerRadioItem>
)
})}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
)
}

View File

@@ -0,0 +1,115 @@
"use client"
import * as React from "react"
import type { DesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
// Uncommitted params applied to the preview while hovering a picker item.
// Kept out of the URL on purpose: committing to the URL would re-encode the
// preset code and push a history entry for every hovered item, polluting
// undo/redo and shareable links. The preview iframe receives these via the
// same postMessage channel as committed params and reverts when cleared.
type PreviewOverride = Partial<DesignSystemSearchParams> | null
// How long the pointer must settle before a preview applies. Every mousemove
// over a picker item re-arms this trailing timer, so nothing applies while
// the cursor is in motion — each application is a full theme rewrite in the
// iframe. Clears are never debounced: reverting on leave/Escape must feel
// instant.
const PREVIEW_OVERRIDE_DEBOUNCE_MS = 50
// The value and the actions live in separate contexts so pickers (which only
// call the stable setters) never re-render while a preview is applied. Only
// the preview iframe host subscribes to the value.
const PreviewOverrideValueContext = React.createContext<PreviewOverride>(null)
const PreviewOverrideActionsContext = React.createContext<{
setOverride: (override: Partial<DesignSystemSearchParams>) => void
clearOverride: () => void
} | null>(null)
function isSameOverride(a: PreviewOverride, b: PreviewOverride) {
if (a === b) {
return true
}
if (!a || !b) {
return false
}
const aKeys = Object.keys(a) as (keyof DesignSystemSearchParams)[]
const bKeys = Object.keys(b) as (keyof DesignSystemSearchParams)[]
return (
aKeys.length === bKeys.length && aKeys.every((key) => a[key] === b[key])
)
}
export function PreviewOverrideProvider({
children,
}: {
children: React.ReactNode
}) {
const [override, setOverrideState] = React.useState<PreviewOverride>(null)
const timeoutRef = React.useRef<number | null>(null)
const cancelPendingOverride = React.useCallback(() => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [])
React.useEffect(() => cancelPendingOverride, [cancelPendingOverride])
const setOverride = React.useCallback(
(next: Partial<DesignSystemSearchParams>) => {
cancelPendingOverride()
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null
// Skip identical updates: mouseenter and focus both fire for the
// hovered item, and hovering the committed value is a no-op.
setOverrideState((current) =>
isSameOverride(current, next) ? current : next
)
}, PREVIEW_OVERRIDE_DEBOUNCE_MS)
},
[cancelPendingOverride]
)
const clearOverride = React.useCallback(() => {
cancelPendingOverride()
setOverrideState(null)
}, [cancelPendingOverride])
const actions = React.useMemo(
() => ({ setOverride, clearOverride }),
[setOverride, clearOverride]
)
return (
<PreviewOverrideActionsContext.Provider value={actions}>
<PreviewOverrideValueContext.Provider value={override}>
{children}
</PreviewOverrideValueContext.Provider>
</PreviewOverrideActionsContext.Provider>
)
}
// For pickers: stable setters only, never re-renders on override changes.
export function usePreviewOverride() {
const context = React.useContext(PreviewOverrideActionsContext)
if (!context) {
throw new Error(
"usePreviewOverride must be used within a PreviewOverrideProvider."
)
}
return context
}
// For the preview iframe host: the current override value.
export function usePreviewOverrideValue() {
return React.useContext(PreviewOverrideValueContext)
}

View File

@@ -0,0 +1,16 @@
"use client"
export function PreviewStyle() {
return (
<style jsx global>{`
html {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
`}</style>
)
}

View File

@@ -0,0 +1,37 @@
"use client"
import { Button } from "@/registry/new-york-v4/ui/button"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
const PREVIEW_ITEMS = [
{ label: "01", value: "preview-02" },
{ label: "02", value: "preview" },
]
export function PreviewSwitcher() {
const [params, setParams] = useDesignSystemSearchParams()
const isPreview =
params.item === "preview" || params.item.startsWith("preview-0")
if (!isPreview) {
return null
}
return (
<div className="dark absolute right-3 bottom-3 z-20 flex items-center gap-1 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
{PREVIEW_ITEMS.map((item) => (
<Button
key={item.value}
variant="ghost"
size="sm"
data-active={params.item === item.value}
className="h-7 min-w-8 cursor-pointer rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
onClick={() => setParams({ item: item.value })}
>
{item.label}
</Button>
))}
</div>
)
}

View File

@@ -0,0 +1,197 @@
"use client"
import * as React from "react"
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/(create)/components/action-menu"
import { CreateDevtools } from "@/app/(app)/(create)/components/create-devtools"
import {
REDO_FORWARD_TYPE,
UNDO_FORWARD_TYPE,
} from "@/app/(app)/(create)/components/history-buttons"
import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/(create)/components/mode-switcher"
import { usePreviewOverrideValue } from "@/app/(app)/(create)/components/preview-override"
import { PreviewSwitcher } from "@/app/(app)/(create)/components/preview-switcher"
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/(create)/components/random-button"
import { sendToIframe } from "@/app/(app)/(create)/hooks/use-iframe-sync"
import { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/(create)/hooks/use-open-preset"
import { RESET_FORWARD_TYPE } from "@/app/(app)/(create)/hooks/use-reset"
import {
serializeDesignSystemSearchParams,
useDesignSystemSearchParams,
} from "@/app/(app)/(create)/lib/search-params"
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
function isSameParams(
a: ReturnType<typeof useDesignSystemSearchParams>[0] | null,
b: ReturnType<typeof useDesignSystemSearchParams>[0]
) {
if (!a) {
return false
}
return Object.keys(b).every(
(key) => a[key as keyof typeof b] === b[key as keyof typeof b]
)
}
export function Preview() {
const [params] = useDesignSystemSearchParams()
const override = usePreviewOverrideValue()
const iframeRef = React.useRef<HTMLIFrameElement>(null)
const lastSentParamsRef = React.useRef<typeof params | null>(null)
// Hover previews overlay the committed params without touching the URL —
// clearing the override re-sends the committed params, reverting the preview.
const mergedParams = React.useMemo(
() => (override ? { ...params, ...override } : params),
[params, override]
)
React.useEffect(() => {
const iframe = iframeRef.current
if (!iframe) {
return
}
const sendParams = () => {
sendToIframe(iframe, "design-system-params", mergedParams)
lastSentParamsRef.current = mergedParams
}
// Skip content-identical re-sends (e.g. an override matching the committed
// params) — each message triggers a full param sync in the iframe.
if (
iframe.contentWindow &&
!isSameParams(lastSentParamsRef.current, mergedParams)
) {
sendParams()
}
iframe.addEventListener("load", sendParams)
return () => {
iframe.removeEventListener("load", sendParams)
}
}, [mergedParams])
React.useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const iframeWindow = iframeRef.current?.contentWindow
if (
!iframeWindow ||
event.origin !== window.location.origin ||
event.source !== iframeWindow ||
!event.data ||
typeof event.data !== "object"
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === OPEN_PRESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "o",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "R",
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [])
const iframeSrc = React.useMemo(() => {
// The iframe src needs to include the serialized design system params
// for the initial load, but not be reactive to them as it would cause
// full-iframe reloads on every param change (flashes & loss of state).
// Further updates of the search params will be sent to the iframe
// via a postMessage channel, for it to sync its own history onto the host's.
return serializeDesignSystemSearchParams(
`/preview/${params.base}/${params.item}`,
params
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.base, params.item])
return (
<div className="relative flex flex-1 flex-col justify-center overflow-hidden rounded-2xl ring ring-foreground/10 md:ring-muted dark:ring-foreground/10">
<div className="relative z-0 mx-auto flex w-full flex-1 flex-col overflow-hidden">
<div className="absolute inset-0 bg-muted dark:bg-muted/30" />
<iframe
key={params.base + params.item}
ref={iframeRef}
src={iframeSrc}
className="z-10 size-full flex-1"
title="Preview"
/>
</div>
<CreateDevtools />
<PreviewSwitcher />
</div>
)
}

View File

@@ -0,0 +1,728 @@
"use client"
import * as React from "react"
import {
Copy01Icon,
Globe02Icon,
HandPointingRight04Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "cn"
import { useConfig } from "@/hooks/use-config"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import {
BASES,
buildThemeForPreset,
DEFAULT_CONFIG,
type BaseName,
type DesignSystemConfig,
} from "@/registry/config"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/styles/base-nova/ui/field"
import { RadioGroup, RadioGroupItem } from "@/styles/base-nova/ui/radio-group"
import { Switch } from "@/styles/base-nova/ui/switch"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/styles/base-nova/ui/tabs"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/styles/base-nova/ui/toggle-group"
import { usePresetCode } from "@/app/(app)/(create)/hooks/use-design-system"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(app)/(create)/lib/search-params"
import {
getFramework,
getTemplateValue,
NO_MONOREPO_FRAMEWORKS,
TEMPLATES,
} from "@/app/(app)/(create)/lib/templates"
const TURBOREPO_LOGO =
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Turborepo</title><path d="M11.9906 4.1957c-4.2998 0-7.7981 3.501-7.7981 7.8043s3.4983 7.8043 7.7981 7.8043c4.2999 0 7.7982-3.501 7.7982-7.8043s-3.4983-7.8043-7.7982-7.8043m0 11.843c-2.229 0-4.0356-1.8079-4.0356-4.0387s1.8065-4.0387 4.0356-4.0387S16.0262 9.7692 16.0262 12s-1.8065 4.0388-4.0356 4.0388m.6534-13.1249V0C18.9726.3386 24 5.5822 24 12s-5.0274 11.66-11.356 12v-2.9139c4.7167-.3372 8.4516-4.2814 8.4516-9.0861s-3.735-8.749-8.4516-9.0861M5.113 17.9586c-1.2502-1.4446-2.0562-3.2845-2.2-5.3046H0c.151 2.8266 1.2808 5.3917 3.051 7.3668l2.0606-2.0622zM11.3372 24v-2.9139c-2.02-.1439-3.8584-.949-5.3019-2.2018l-2.0606 2.0623c1.975 1.773 4.538 2.9022 7.361 3.0534z"/></svg>'
const ORIGIN = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"
const IS_LOCAL_DEV = ORIGIN.includes("localhost")
const SHADCN_VERSION = process.env.NEXT_PUBLIC_RC ? "@rc" : "@latest"
const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"] as const
type PackageManager = (typeof PACKAGE_MANAGERS)[number]
const APPLY_MODES = [
{
value: "full",
title: "Full preset",
description:
"Everything from the preset, including components, theme, and fonts.",
flag: null,
label: "full preset",
},
{
value: "theme",
title: "Theme only",
description:
"Theme tokens only, like colors, radii, and shadows. Components stay as they are.",
flag: "--only theme",
label: "--only theme",
},
{
value: "font",
title: "Fonts only",
description:
"Only preset fonts for body and headings. Components stay as they are.",
flag: "--only font",
label: "--only font",
},
] as const
type ApplyMode = (typeof APPLY_MODES)[number]["value"]
type ProjectFormTab = "new-project" | "existing-project" | "theme"
type CopyTarget = "command" | "apply" | "theme"
type ThemeCssVars = NonNullable<
ReturnType<typeof buildThemeForPreset>["cssVars"]
>
function formatCssVarsRule(selector: string, cssVars?: Record<string, string>) {
const declarations = Object.entries(cssVars ?? {})
.map(([key, value]) => ` --${key}: ${value};`)
.join("\n")
return `${selector} {\n${declarations}\n}`
}
function formatThemeCss(cssVars: ThemeCssVars) {
return [
formatCssVarsRule(":root", cssVars.light),
formatCssVarsRule(".dark", cssVars.dark),
].join("\n\n")
}
export function ProjectForm({
className,
}: React.ComponentProps<typeof Button>) {
const [open, setOpen] = React.useState(false)
const [params, setParams] = useDesignSystemSearchParams()
const presetCode = usePresetCode()
const [config, setConfig] = useConfig()
const [copiedTarget, setCopiedTarget] = React.useState<CopyTarget | null>(
null
)
const [applyMode, setApplyMode] = React.useState<ApplyMode>("full")
const [activeTab, setActiveTab] =
React.useState<ProjectFormTab>("new-project")
const packageManager = (config.packageManager || "pnpm") as PackageManager
const framework = React.useMemo(
() => getFramework(params.template ?? "next"),
[params.template]
)
const isMonorepo = React.useMemo(
() => params.template?.endsWith("-monorepo") ?? false,
[params.template]
)
const hasMonorepo = !NO_MONOREPO_FRAMEWORKS.includes(
framework as (typeof NO_MONOREPO_FRAMEWORKS)[number]
)
const commands = React.useMemo(() => {
const presetFlag = ` --preset ${presetCode}`
const baseFlag =
params.base !== DEFAULT_CONFIG.base ? ` --base ${params.base}` : ""
const templateFlag = ` --template ${framework}`
const monorepoFlag = isMonorepo ? " --monorepo" : ""
const rtlFlag = params.rtl ? " --rtl" : ""
const pointerFlag = params.pointer ? " --pointer" : ""
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}${pointerFlag}`
return IS_LOCAL_DEV
? {
pnpm: `shadcn init${flags}`,
npm: `shadcn init${flags}`,
yarn: `shadcn init${flags}`,
bun: `shadcn init${flags}`,
}
: {
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} init${flags}`,
npm: `npx shadcn${SHADCN_VERSION} init${flags}`,
yarn: `yarn dlx shadcn${SHADCN_VERSION} init${flags}`,
bun: `bunx --bun shadcn${SHADCN_VERSION} init${flags}`,
}
}, [
framework,
isMonorepo,
params.base,
params.pointer,
params.rtl,
presetCode,
])
const command = commands[packageManager]
const applyCommands = React.useMemo(() => {
const presetFlag = ` --preset ${presetCode}`
const onlyFlag =
applyMode === "theme"
? " --only theme"
: applyMode === "font"
? " --only font"
: ""
const flags = `${presetFlag}${onlyFlag}`
return IS_LOCAL_DEV
? {
pnpm: `shadcn apply${flags}`,
npm: `shadcn apply${flags}`,
yarn: `shadcn apply${flags}`,
bun: `shadcn apply${flags}`,
}
: {
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} apply${flags}`,
npm: `npx shadcn${SHADCN_VERSION} apply${flags}`,
yarn: `yarn dlx shadcn${SHADCN_VERSION} apply${flags}`,
bun: `bunx --bun shadcn${SHADCN_VERSION} apply${flags}`,
}
}, [applyMode, presetCode])
const applyCommand = applyCommands[packageManager]
const themeConfig = React.useMemo<DesignSystemConfig>(() => {
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
return {
...DEFAULT_CONFIG,
base: params.base,
style: params.style,
baseColor: params.baseColor,
theme: params.theme,
chartColor: params.chartColor,
iconLibrary: params.iconLibrary,
font: params.font,
fontHeading: params.fontHeading,
menuAccent: params.menuAccent,
menuColor: params.menuColor,
radius: isRadiusLocked ? "none" : params.radius,
template: params.template,
rtl: params.rtl,
pointer: params.pointer,
}
}, [
params.base,
params.baseColor,
params.chartColor,
params.font,
params.fontHeading,
params.iconLibrary,
params.menuAccent,
params.menuColor,
params.pointer,
params.radius,
params.rtl,
params.style,
params.template,
params.theme,
])
const themeCss = React.useMemo(() => {
const theme = buildThemeForPreset(themeConfig)
if (!theme.cssVars) {
return ""
}
return formatThemeCss(theme.cssVars)
}, [themeConfig])
React.useEffect(() => {
if (copiedTarget) {
const timer = setTimeout(() => setCopiedTarget(null), 2000)
return () => clearTimeout(timer)
}
}, [copiedTarget])
const handleCopy = React.useCallback(() => {
const properties: Record<string, string> = {
command,
}
if (params.template) {
properties.template = params.template
}
copyToClipboardWithMeta(command, {
name: "copy_npm_command",
properties,
})
setCopiedTarget("command")
}, [command, params.template])
const handleCopyApply = React.useCallback(() => {
copyToClipboardWithMeta(applyCommand, {
name: "copy_apply_command",
properties: {
command: applyCommand,
applyMode,
},
})
setCopiedTarget("apply")
}, [applyCommand, applyMode])
const handleCopyTheme = React.useCallback(() => {
copyToClipboardWithMeta(themeCss, {
name: "copy_theme_code",
properties: {
preset: presetCode,
baseColor: params.baseColor,
theme: params.theme,
format: "css",
},
})
setCopiedTarget("theme")
}, [params.baseColor, params.theme, presetCode, themeCss])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button className={cn(className)} />}>
Get Code
</DialogTrigger>
<DialogContent className="dark top-[64px] no-scrollbar flex max-h-[calc(100svh-2rem)] translate-y-0 flex-col rounded-2xl p-0 shadow-xl **:data-[slot=dialog-close]:top-4.5 **:data-[slot=dialog-close]:right-4 **:data-[slot=field-separator]:h-2 sm:max-w-md">
<div className="flex min-w-0 flex-1 flex-col gap-0 overflow-hidden rounded-2xl">
<DialogHeader className="border-b px-6 py-5">
<ToggleGroup
value={[activeTab]}
onValueChange={(values) =>
setActiveTab((values[0] as typeof activeTab) ?? "new-project")
}
aria-label="Project type"
spacing={2}
className="**:data-[slot=toggle-group-item]:data-pressed:bg-neutral-700/70"
>
<ToggleGroupItem value="new-project">New Project</ToggleGroupItem>
<ToggleGroupItem value="existing-project">
Existing Project
</ToggleGroupItem>
<ToggleGroupItem value="theme">Theme</ToggleGroupItem>
</ToggleGroup>
</DialogHeader>
{activeTab === "new-project" && (
<div className="no-scrollbar overflow-y-auto">
<FieldGroup className="px-6 py-4">
<Field className="gap-3">
<FieldLabel>Template</FieldLabel>
<TemplateGrid
template={params.template}
setParams={setParams}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field>
<FieldLabel>Base</FieldLabel>
<BaseGrid base={params.base} setParams={setParams} />
</Field>
<FieldSeparator className="-mx-6" />
<FieldSet>
<FieldLegend variant="label" className="sr-only">
Options
</FieldLegend>
<Field orientation="horizontal">
<FieldLabel htmlFor="pointer">
<HugeiconsIcon
icon={HandPointingRight04Icon}
className="size-4 -rotate-90"
/>
Use pointer on buttons
</FieldLabel>
<Switch
id="pointer"
checked={params.pointer}
onCheckedChange={(checked) =>
setParams({ pointer: checked === true })
}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field
orientation="horizontal"
data-disabled={hasMonorepo ? undefined : "true"}
>
<FieldLabel htmlFor="monorepo">
<span
className="size-4 text-neutral-100 [&_svg]:size-4 [&_svg]:fill-current"
dangerouslySetInnerHTML={{
__html: TURBOREPO_LOGO,
}}
/>
Create a monorepo
</FieldLabel>
<Switch
id="monorepo"
checked={params.template?.endsWith("-monorepo") ?? false}
disabled={!hasMonorepo}
onCheckedChange={(checked) => {
const framework = getFramework(
params.template ?? "next"
)
setParams({
template: getTemplateValue(
framework,
checked === true
) as typeof params.template,
})
}}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field orientation="horizontal">
<FieldLabel htmlFor="rtl">
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
Enable RTL support
</FieldLabel>
<Switch
id="rtl"
checked={params.rtl}
onCheckedChange={(checked) =>
setParams({ rtl: checked === true })
}
/>
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter className="m-0 min-w-0 p-6">
<div className="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-xl border-0 ring-1 ring-border"
>
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
<TabsList className="bg-transparent font-mono">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger
key={manager}
value={manager}
className="py-0 leading-none data-[state=active]:shadow-none"
>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
>
{copiedTarget === "command" ? (
<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 bg-popover p-3">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
</div>
</div>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopy} className="h-9 w-full">
{copiedTarget === "command" ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</div>
)}
{activeTab === "existing-project" && (
<div className="no-scrollbar overflow-y-auto">
<FieldGroup className="px-6 py-4">
<FieldSet className="gap-3">
<FieldLegend variant="label">Apply Preset</FieldLegend>
<FieldDescription>
Pick which parts of the preset to apply.
</FieldDescription>
<ApplyModeGrid mode={applyMode} setMode={setApplyMode} />
</FieldSet>
</FieldGroup>
<DialogFooter className="m-0 min-w-0 p-6">
<div className="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-xl border-0 ring-1 ring-border"
>
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
<TabsList className="bg-transparent font-mono">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger
key={manager}
value={manager}
className="py-0 leading-none data-[state=active]:shadow-none"
>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopyApply}
>
{copiedTarget === "apply" ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(applyCommands).map(([key, cmd]) => {
return (
<TabsContent key={key} value={key}>
<div className="relative overflow-hidden border-t bg-popover p-3">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
</div>
</div>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopyApply} className="h-9 w-full">
{copiedTarget === "apply" ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</div>
)}
{activeTab === "theme" && (
<div className="no-scrollbar overflow-y-auto">
<FieldGroup className="min-w-0 px-6 py-4">
<FieldSet className="min-w-0 gap-3">
<FieldLegend variant="label">Theme Tokens</FieldLegend>
<FieldDescription>
Copy the CSS variables for this preset.
</FieldDescription>
<div className="w-full min-w-0 overflow-hidden rounded-xl border-0 ring-1 ring-border">
<div className="flex items-center gap-2 py-1 pr-1.5 pl-3">
<div className="min-w-0 truncate font-mono text-sm text-muted-foreground">
globals.css
</div>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopyTheme}
>
{copiedTarget === "theme" ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy theme</span>
</Button>
</div>
<div className="relative no-scrollbar max-h-[45svh] overflow-auto border-t bg-popover p-3">
<pre className="min-w-max font-mono leading-normal whitespace-pre">
<code>{themeCss}</code>
</pre>
</div>
</div>
</FieldSet>
</FieldGroup>
<DialogFooter className="m-0 min-w-0 p-6">
<Button onClick={handleCopyTheme} className="h-9 w-full">
{copiedTarget === "theme" ? "Copied" : "Copy Theme"}
</Button>
</DialogFooter>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
const TemplateGrid = React.memo(function TemplateGrid({
template,
setParams,
}: {
template: DesignSystemSearchParams["template"]
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
}) {
const isMonorepo = template?.endsWith("-monorepo") ?? false
const framework = getFramework(template ?? "next")
const handleTemplateChange = React.useCallback(
(value: string) => {
setParams({
template: getTemplateValue(
value,
isMonorepo
) as DesignSystemSearchParams["template"],
})
},
[isMonorepo, setParams]
)
return (
<RadioGroup
value={framework}
onValueChange={handleTemplateChange}
className="grid grid-cols-2 gap-2"
>
{TEMPLATES.map((item) => (
<FieldLabel
key={item.value}
htmlFor={`template-${item.value}`}
className="block w-full"
>
<Field
orientation="horizontal"
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
>
<FieldContent className="flex flex-row items-center gap-2 px-2.5 py-1.5">
<div
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
dangerouslySetInnerHTML={{
__html: item.logo,
}}
></div>
<FieldTitle>{item.title}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={item.value}
id={`template-${item.value}`}
className="sr-only absolute"
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})
const ApplyModeGrid = React.memo(function ApplyModeGrid({
mode,
setMode,
}: {
mode: ApplyMode
setMode: (mode: ApplyMode) => void
}) {
return (
<RadioGroup
value={mode}
onValueChange={(value) => setMode(value as ApplyMode)}
aria-label="Apply"
>
{APPLY_MODES.map((option) => (
<FieldLabel key={option.value} htmlFor={`apply-${option.value}`}>
<Field orientation="horizontal">
<RadioGroupItem value={option.value} id={`apply-${option.value}`} />
<FieldContent>
<FieldTitle>{option.title}</FieldTitle>
<FieldDescription>{option.description}</FieldDescription>
</FieldContent>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})
const BaseGrid = React.memo(function BaseGrid({
base,
setParams,
}: {
base: DesignSystemSearchParams["base"]
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
}) {
const handleBaseChange = React.useCallback(
(value: string) => {
setParams({ base: value as BaseName })
},
[setParams]
)
return (
<RadioGroup
value={base}
onValueChange={handleBaseChange}
aria-label="Base"
className="grid grid-cols-1 gap-2 sm:grid-cols-3"
>
{BASES.map((item) => (
<FieldLabel
key={item.name}
htmlFor={`base-${item.name}`}
className="block w-full"
>
<Field
orientation="horizontal"
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
>
<FieldContent className="flex flex-row items-center gap-2 py-1">
<div
className="size-4 shrink-0 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
dangerouslySetInnerHTML={{
__html: item.meta?.logo ?? "",
}}
/>
<FieldTitle className="whitespace-nowrap">
{item.title}
</FieldTitle>
</FieldContent>
<RadioGroupItem
value={item.name}
id={`base-${item.name}`}
className="sr-only absolute"
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})

View File

@@ -0,0 +1,125 @@
"use client"
import * as React from "react"
import { RADII, type RadiusValue } from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { usePreviewOverride } from "@/app/(app)/(create)/components/preview-override"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function RadiusPicker({
isMobile,
anchorRef,
}: {
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const { setOverride, clearOverride } = usePreviewOverride()
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
const selectedRadiusName = isRadiusLocked ? "none" : params.radius
const currentRadius = RADII.find(
(radius) => radius.name === selectedRadiusName
)
const defaultRadius = RADII.find((radius) => radius.name === "default")
const otherRadii = RADII.filter((radius) => radius.name !== "default")
return (
<div className="group/picker relative">
<Picker
onOpenChange={(open) => {
if (!open) {
clearOverride()
}
}}
>
<PickerTrigger disabled={isRadiusLocked}>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Radius</div>
<div className="text-sm font-medium text-foreground">
{currentRadius?.label}
</div>
</div>
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 rotate-90 items-center justify-center text-base text-foreground select-none md:right-2.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
className="text-foreground"
>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 20v-5C4 8.925 8.925 4 15 4h5"
/>
</svg>
</div>
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
onMouseLeave={clearOverride}
>
<PickerRadioGroup
value={currentRadius?.name}
onValueChange={(value) => {
if (isRadiusLocked) {
return
}
setParams({ radius: value as RadiusValue })
}}
onItemPreview={
isMobile || isRadiusLocked
? undefined
: (value) => setOverride({ radius: value as RadiusValue })
}
>
<PickerGroup>
{defaultRadius && (
<PickerRadioItem
key={defaultRadius.name}
value={defaultRadius.name}
closeOnClick={isMobile}
>
{defaultRadius.label}
</PickerRadioItem>
)}
</PickerGroup>
<PickerSeparator />
<PickerGroup>
{otherRadii.map((radius) => (
<PickerRadioItem
key={radius.name}
value={radius.name}
closeOnClick={isMobile}
disabled={params.style === "rhea" && radius.name === "large"}
>
{radius.label}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="radius"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -0,0 +1,73 @@
"use client"
import Script from "next/script"
import { DiceFaces05Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "cn"
import { Button } from "@/styles/base-nova/ui/button"
import { useRandom } from "@/app/(app)/(create)/hooks/use-random"
import { RESET_FORWARD_TYPE } from "@/app/(app)/(create)/hooks/use-reset"
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
export function RandomButton({
variant = "outline",
className,
...props
}: React.ComponentProps<typeof Button>) {
const { randomize } = useRandom()
return (
<Button
variant={variant}
onClick={randomize}
className={cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)}
{...props}
>
<span className="w-full truncate text-center font-medium">Shuffle</span>
</Button>
)
}
export function RandomizeScript() {
return (
<Script
id="randomize-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward r key (shuffle) and Shift+R (reset).
document.addEventListener('keydown', function(e) {
if ((e.key === 'r' || e.key === 'R') && !e.metaKey && !e.ctrlKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
var type = e.shiftKey
? '${RESET_FORWARD_TYPE}'
: '${RANDOMIZE_FORWARD_TYPE}';
window.parent.postMessage({
type: type,
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -0,0 +1,34 @@
"use client"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/styles/base-nova/ui/alert-dialog"
import { useReset } from "@/app/(app)/(create)/hooks/use-reset"
export function ResetDialog() {
const { showResetDialog, setShowResetDialog, confirmReset } = useReset()
return (
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Reset to defaults?</AlertDialogTitle>
<AlertDialogDescription>
This will reset all customization options to their default values.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset}>Reset</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { Share03Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/styles/base-nova/ui/button"
import { usePresetCode } from "@/app/(app)/(create)/hooks/use-design-system"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function ShareButton() {
const [params] = useDesignSystemSearchParams()
const presetCode = usePresetCode()
const [hasCopied, setHasCopied] = React.useState(false)
const shareUrl = React.useMemo(() => {
const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
const searchParams = new URLSearchParams({
preset: presetCode,
item: params.item,
})
if (params.pointer) {
searchParams.set("pointer", "true")
}
return `${origin}/create?${searchParams.toString()}`
}, [params.item, params.pointer, presetCode])
React.useEffect(() => {
if (hasCopied) {
const timer = setTimeout(() => setHasCopied(false), 2000)
return () => clearTimeout(timer)
}
}, [hasCopied])
const handleCopy = React.useCallback(() => {
copyToClipboardWithMeta(shareUrl, {
name: "copy_create_share_url",
properties: {
url: shareUrl,
},
})
setHasCopied(true)
}, [shareUrl])
return (
<Button variant="outline" className="hidden md:flex" onClick={handleCopy}>
{hasCopied ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
data-icon="inline-start"
/>
) : (
<HugeiconsIcon
icon={Share03Icon}
strokeWidth={2}
data-icon="inline-start"
/>
)}
Share
</Button>
)
}

View File

@@ -0,0 +1,93 @@
"use client"
import * as React from "react"
import { type Style, type StyleName } from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { usePreviewOverride } from "@/app/(app)/(create)/components/preview-override"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function StylePicker({
styles,
isMobile,
anchorRef,
}: {
styles: readonly Style[]
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const { setOverride, clearOverride } = usePreviewOverride()
const currentStyle = styles.find((style) => style.name === params.style)
return (
<div className="group/picker relative">
<Picker
onOpenChange={(open) => {
if (!open) {
clearOverride()
}
}}
>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Style</div>
<div className="text-sm font-medium text-foreground">
{currentStyle?.title}
</div>
</div>
{currentStyle?.icon && (
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center select-none md:right-2.5">
{React.cloneElement(currentStyle.icon, {
className: "size-4",
})}
</div>
)}
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
onMouseLeave={clearOverride}
>
<PickerRadioGroup
value={currentStyle?.name}
onValueChange={(value) => {
setParams({ style: value as StyleName })
}}
onItemPreview={
isMobile
? undefined
: (value) => setOverride({ style: value as StyleName })
}
>
<PickerGroup>
{styles.map((style) => (
<PickerRadioItem
value={style.name}
key={style.name}
closeOnClick={isMobile}
>
{style.title}
</PickerRadioItem>
))}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="style"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { useMounted } from "@/hooks/use-mounted"
import { BASE_COLORS, type Theme, type ThemeName } from "@/registry/config"
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
import {
Picker,
PickerContent,
PickerGroup,
PickerRadioGroup,
PickerRadioItem,
PickerSeparator,
PickerTrigger,
} from "@/app/(app)/(create)/components/picker"
import { usePreviewOverride } from "@/app/(app)/(create)/components/preview-override"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function ThemePicker({
themes,
isMobile,
anchorRef,
}: {
themes: readonly Theme[]
isMobile: boolean
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const mounted = useMounted()
const [params, setParams] = useDesignSystemSearchParams()
const { setOverride, clearOverride } = usePreviewOverride()
const currentTheme = React.useMemo(
() => themes.find((theme) => theme.name === params.theme),
[themes, params.theme]
)
const currentThemeIsBaseColor = React.useMemo(
() => BASE_COLORS.find((baseColor) => baseColor.name === params.theme),
[params.theme]
)
return (
<div className="group/picker relative">
<Picker
onOpenChange={(open) => {
if (!open) {
clearOverride()
}
}}
>
<PickerTrigger>
<div className="flex flex-col justify-start text-left">
<div className="text-xs text-muted-foreground">Theme</div>
<div className="text-sm font-medium text-foreground">
{currentTheme?.title}
</div>
</div>
{mounted && (
<div
style={
{
"--color":
currentTheme?.cssVars?.dark?.[
currentThemeIsBaseColor ? "muted-foreground" : "primary"
],
} as React.CSSProperties
}
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 rounded-full bg-(--color) select-none md:right-2.5"
/>
)}
</PickerTrigger>
<PickerContent
anchor={isMobile ? anchorRef : undefined}
side={isMobile ? "top" : "right"}
align={isMobile ? "center" : "start"}
className="max-h-92"
onMouseLeave={clearOverride}
>
<PickerRadioGroup
value={currentTheme?.name}
onValueChange={(value) => {
setParams({ theme: value as ThemeName })
}}
onItemPreview={
isMobile
? undefined
: (value) => setOverride({ theme: value as ThemeName })
}
>
<PickerGroup>
{themes
.filter((theme) =>
BASE_COLORS.find((baseColor) => baseColor.name === theme.name)
)
.map((theme) => {
return (
<PickerRadioItem
key={theme.name}
value={theme.name}
closeOnClick={isMobile}
>
{theme.title}
</PickerRadioItem>
)
})}
</PickerGroup>
<PickerSeparator />
<PickerGroup>
{themes
.filter(
(theme) =>
!BASE_COLORS.find(
(baseColor) => baseColor.name === theme.name
)
)
.map((theme) => {
return (
<PickerRadioItem
key={theme.name}
value={theme.name}
closeOnClick={isMobile}
>
{theme.title}
</PickerRadioItem>
)
})}
</PickerGroup>
</PickerRadioGroup>
</PickerContent>
</Picker>
<LockButton
param="theme"
className="absolute top-1/2 right-8 -translate-y-1/2"
/>
</div>
)
}

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { useIsMobile } from "@/hooks/use-mobile"
import { useMounted } from "@/hooks/use-mounted"
import { Icons } from "@/components/icons"
import { Button } from "@/styles/base-nova/ui/button"
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
export function V0Button({ className }: { className?: string }) {
const [params] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const isMounted = useMounted()
const url = React.useMemo(() => {
const searchParams = new URLSearchParams()
if (params.preset) {
searchParams.set("preset", params.preset)
}
searchParams.set("base", params.base)
return `${process.env.NEXT_PUBLIC_APP_URL}/init/v0?${searchParams.toString()}`
}, [params.preset, params.base])
const title = React.useMemo(() => {
return params.base && params.style
? `New ${params.base}-${params.style} project`
: "New Project"
}, [params.base, params.style])
if (!isMounted) {
return <Skeleton className="h-8 w-24 rounded-lg" />
}
return (
<Button
nativeButton={false}
role="link"
variant={isMobile ? "default" : "outline"}
className={cn("h-[31px] gap-1 rounded-lg", className)}
render={
<a
href={`${process.env.NEXT_PUBLIC_V0_URL}/chat/api/open?url=${url}&title=${title}`}
target="_blank"
/>
}
>
<span>Open in</span>
<Icons.v0 className="size-5" data-icon="inline-end" />
</Button>
)
}

View File

@@ -0,0 +1,68 @@
"use client"
import * as React from "react"
import { Icons } from "@/components/icons"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/styles/base-nova/ui/dialog"
const STORAGE_KEY = "shadcn-create-welcome-dialog"
export function WelcomeDialog() {
const [isOpen, setIsOpen] = React.useState(false)
React.useEffect(() => {
const dismissed = localStorage.getItem(STORAGE_KEY)
if (!dismissed) {
setIsOpen(true)
}
}, [])
// Stable callback — avoids re-creation on every render. (rerender-functional-setstate)
const handleOpenChange = React.useCallback((open: boolean) => {
setIsOpen(open)
if (!open) {
localStorage.setItem(STORAGE_KEY, "true")
}
}, [])
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className="dialog-ring max-w-92 min-w-0 gap-0 overflow-hidden rounded-xl p-0 sm:max-w-sm dark:bg-neutral-900"
>
<div className="flex aspect-[2/1.2] w-full items-center justify-center rounded-t-xl bg-neutral-950 text-center text-neutral-100 sm:aspect-2/1">
<div className="font-mono text-2xl font-bold">
<Icons.logo className="size-12" />
</div>
</div>
<DialogHeader className="gap-1 p-4">
<DialogTitle className="text-left text-base">
Build your own shadcn/ui
</DialogTitle>
<DialogDescription className="text-left leading-relaxed text-foreground">
Customize everything from the ground up. Pick your component
library, font, color scheme, and more.
</DialogDescription>
<DialogDescription className="mt-2 text-left leading-relaxed font-medium text-foreground">
Available for all major React frameworks.
</DialogDescription>
</DialogHeader>
<DialogFooter className="m-0">
<DialogClose render={<Button className="w-full" />}>
Get Started
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}