refactor: rework create page

This commit is contained in:
shadcn
2026-02-26 13:12:35 +04:00
parent 117136ada3
commit 9546f3ad1e
29 changed files with 1488 additions and 822 deletions

View File

@@ -0,0 +1,390 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { Button } from "@/examples/base/ui/button"
import {
Combobox,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
} from "@/examples/base/ui/combobox"
import { CheckIcon } from "lucide-react"
import { type RegistryItem } from "shadcn/schema"
import {
BASE_COLORS,
BASES,
getThemesForBaseColor,
iconLibraries,
MENU_ACCENTS,
MENU_COLORS,
RADII,
STYLES,
} from "@/registry/config"
import { FONTS } from "@/app/(create)/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import { groupItemsByType } from "@/app/(create)/lib/utils"
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"
type DesignSystemSearchParams = ReturnType<
typeof useDesignSystemSearchParams
>[0]
type DesignSystemParamKey =
| "style"
| "baseColor"
| "theme"
| "iconLibrary"
| "font"
| "radius"
| "menuColor"
| "menuAccent"
| "base"
type RegistryActionMenuItem = {
id: string
type: string
label: string
registryName: string
}
type DesignSystemActionMenuItem = {
id: string
type: string
label: string
paramKey: DesignSystemParamKey
paramValue: string
}
type ActionMenuItem = RegistryActionMenuItem | DesignSystemActionMenuItem
type ActionMenuGroup = {
type: string
title: string
items: ActionMenuItem[]
}
const cachedGroupedItems = React.cache(
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {
return groupItemsByType(items)
}
)
const comboboxTriggerButton = (
<Button
variant="outline"
aria-label="Select item"
className="data-popup-open:bg-muted dark:data-popup-open:bg-muted/50 bg-muted/50 sm:bg-background md:dark:bg-background border-foreground/10 dark:bg-muted/50 h-[calc(--spacing(13.5))] w-full flex-1 touch-manipulation justify-between gap-2 rounded-xl pr-4! pl-2.5 text-left shadow-none select-none *:data-[slot=combobox-trigger-icon]:hidden sm:h-8 sm:rounded-lg sm:pr-2!"
/>
)
// Hoisted static element. (rendering-hoist-jsx)
const comboboxEmpty = <ComboboxEmpty>No items found.</ComboboxEmpty>
function createDesignSystemGroup<T>(config: {
paramKey: DesignSystemParamKey
title: string
items: readonly T[]
getValue: (item: T) => string
getLabel: (item: T) => string
}): ActionMenuGroup {
return {
type: `ds:${config.paramKey}`,
title: config.title,
items: config.items.map((item) => ({
id: `${config.paramKey}:${config.getValue(item)}`,
type: `ds:${config.paramKey}`,
label: config.getLabel(item),
paramKey: config.paramKey,
paramValue: config.getValue(item),
})),
}
}
const SEARCH_KEYWORDS: Record<string, string> = {
"ds:font": "font fonts typography",
"ds:radius": "radius rad rounded corner",
"ds:style": "style theme preset",
"ds:baseColor": "base color colours colors",
"ds:theme": "theme appearance mode",
"ds:iconLibrary": "icon library icons",
"ds:menuColor": "menu color colors",
"ds:menuAccent": "menu accent",
"ds:base": "component library base",
"registry:block": "block blocks component components",
"registry:item": "item items component components",
}
export function ActionMenu({
itemsByBase,
}: {
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
}) {
const [open, setOpen] = React.useState(false)
const [params, setParams] = useDesignSystemSearchParams()
const items = React.useMemo(
() => itemsByBase?.[params.base] ?? [],
[itemsByBase, params.base]
)
const groupedRegistryItems = React.useMemo(
() => cachedGroupedItems(items),
[items]
)
const groups = React.useMemo(() => {
const sortedRegistryGroups = [...groupedRegistryItems].sort((a, b) => {
if (a.type === b.type) {
return a.title.localeCompare(b.title)
}
if (a.type === "registry:block") {
return -1
}
if (b.type === "registry:block") {
return 1
}
return a.title.localeCompare(b.title)
})
const registryGroups: ActionMenuGroup[] = sortedRegistryGroups.map(
(group) => ({
type: group.type,
title: group.title,
items: group.items.map((item) => ({
id: `${group.type}:${item.name}`,
type: group.type,
label: item.title ?? item.name,
registryName: item.name,
})),
})
)
return [
...registryGroups,
createDesignSystemGroup({
paramKey: "style",
title: "Style",
items: STYLES,
getValue: (s) => s.name,
getLabel: (s) => s.title,
}),
createDesignSystemGroup({
paramKey: "baseColor",
title: "Base Color",
items: BASE_COLORS,
getValue: (c) => c.name,
getLabel: (c) => c.title ?? c.name,
}),
createDesignSystemGroup({
paramKey: "theme",
title: "Theme",
items: getThemesForBaseColor(params.baseColor),
getValue: (t) => t.name,
getLabel: (t) => t.title ?? t.name,
}),
createDesignSystemGroup({
paramKey: "iconLibrary",
title: "Icon Library",
items: Object.values(iconLibraries),
getValue: (lib) => lib.name,
getLabel: (lib) => lib.title,
}),
createDesignSystemGroup({
paramKey: "font",
title: "Font",
items: FONTS,
getValue: (f) => f.value,
getLabel: (f) => f.name,
}),
createDesignSystemGroup({
paramKey: "radius",
title: "Radius",
items: RADII,
getValue: (r) => r.name,
getLabel: (r) => r.label,
}),
createDesignSystemGroup({
paramKey: "menuColor",
title: "Menu Color",
items: MENU_COLORS,
getValue: (m) => m.value,
getLabel: (m) => m.label,
}),
createDesignSystemGroup({
paramKey: "menuAccent",
title: "Menu Accent",
items: MENU_ACCENTS,
getValue: (a) => a.value,
getLabel: (a) => a.label,
}),
createDesignSystemGroup({
paramKey: "base",
title: "Component Library",
items: BASES,
getValue: (b) => b.name,
getLabel: (b) => b.title ?? b.name,
}),
]
}, [groupedRegistryItems, params.baseColor])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === "k" || e.key === "p") && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen((prev) => !prev)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [])
const handleValueChange = React.useCallback(
(item: ActionMenuItem | null) => {
if (!item || !open) {
return
}
if ("registryName" in item) {
setParams({ item: item.registryName })
} else {
setParams({
[item.paramKey]: item.paramValue,
} as Partial<DesignSystemSearchParams>)
}
setOpen(false)
},
[setParams, open]
)
const isItemActive = React.useCallback(
(item: ActionMenuItem) => {
if ("registryName" in item) {
return params.item === item.registryName
}
return params[item.paramKey] === item.paramValue
},
[params]
)
const handleBackdropClick = React.useCallback(() => {
setOpen(false)
}, [])
return (
<Combobox
autoHighlight
items={groups}
value={null}
onValueChange={handleValueChange}
open={open}
onOpenChange={setOpen}
itemToStringLabel={itemToStringLabel}
itemToStringValue={itemToStringValue}
>
<ComboboxTrigger render={comboboxTriggerButton}>
<ComboboxValue>
<span className="text-muted-foreground flex-1 text-sm">
Quick actions...
</span>
</ComboboxValue>
</ComboboxTrigger>
<ComboboxContent
className="animate-none data-open:animate-none"
side="bottom"
align="center"
>
<ComboboxInput
showTrigger={false}
placeholder="Search"
className="has-focus-visible:border-inherit! has-focus-visible:ring-0!"
/>
{comboboxEmpty}
<ComboboxList>
{(group: ActionMenuGroup) => (
<ComboboxGroup key={group.type} items={group.items}>
<ComboboxLabel>{group.title}</ComboboxLabel>
<ComboboxCollection>
{(item: ActionMenuItem) => (
<ComboboxItem key={item.id} value={item} className="px-2">
{item.label}
{isItemActive(item) ? (
<CheckIcon className="absolute top-1/2 right-2 -translate-y-1/2" />
) : null}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
<div
data-open={open}
className="fixed inset-0 z-50 hidden bg-transparent data-[open=true]:block"
onClick={handleBackdropClick}
/>
</Combobox>
)
}
// Hoisted outside the component — stable reference, never re-created. (rendering-hoist-jsx)
function itemToStringValue(item: ActionMenuItem | null) {
if (!item) {
return ""
}
if ("items" in item) {
return (item as unknown as ActionMenuGroup).title ?? ""
}
return item.label ?? ""
}
function itemToStringLabel(item: ActionMenuItem | null) {
if (!item) {
return ""
}
if ("items" in item) {
return (item as unknown as ActionMenuGroup).title ?? ""
}
return `${item.label ?? ""} ${getSearchKeywordsForType(item.type)}`.trim()
}
function getSearchKeywordsForType(type: string) {
return SEARCH_KEYWORDS[type] ?? type.replace(":", " ")
}
export function ActionMenuScript() {
return (
<Script
id="design-system-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward Cmd/Ctrl + K and Cmd/Ctrl + P
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

@@ -1,11 +1,11 @@
"use client"
import * as React from "react"
import { Button } from "@/examples/base/ui/button"
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/registry/new-york-v4/ui/button"
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
export function CopyPreset() {
@@ -20,7 +20,7 @@ export function CopyPreset() {
}, [hasCopied])
const handleCopy = React.useCallback(() => {
copyToClipboardWithMeta(presetCode, {
copyToClipboardWithMeta(`--preset ${presetCode}`, {
name: "copy_preset_command",
properties: {
preset: presetCode,
@@ -30,19 +30,14 @@ export function CopyPreset() {
}, [presetCode])
return (
<Button
size="sm"
variant="ghost"
onClick={handleCopy}
className="group/button"
>
--preset {presetCode}
<Button variant="ghost" onClick={handleCopy} className="group/button">
<HugeiconsIcon
icon={hasCopied ? Tick02Icon : Copy01Icon}
strokeWidth={2}
className="opacity-0 group-hover/button:opacity-100"
data-icon="inline-end"
data-icon="inline-start"
/>
--preset {presetCode}
</Button>
)
}

View File

@@ -1,20 +1,17 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { Button } from "@/examples/base/ui/button"
import { ArrowLeftIcon } from "lucide-react"
import { FieldGroup } from "@/examples/base/ui/field"
import { useIsMobile } from "@/hooks/use-mobile"
import { ModeSwitcher } from "@/components/mode-switcher"
import { getThemesForBaseColor, STYLES } from "@/registry/config"
import { FieldGroup } from "@/registry/new-york-v4/ui/field"
import { MenuAccentPicker } from "@/app/(create)/components/accent-picker"
import { BaseColorPicker } from "@/app/(create)/components/base-color-picker"
import { BasePicker } from "@/app/(create)/components/base-picker"
import { FontPicker } from "@/app/(create)/components/font-picker"
import { IconLibraryPicker } from "@/app/(create)/components/icon-library-picker"
import { MenuColorPicker } from "@/app/(create)/components/menu-picker"
import { ModeSwitcher } from "@/app/(create)/components/mode-switcher"
import { RadiusPicker } from "@/app/(create)/components/radius-picker"
import { RandomButton } from "@/app/(create)/components/random-button"
import { ResetButton } from "@/app/(create)/components/reset-button"
@@ -23,6 +20,8 @@ import { ThemePicker } from "@/app/(create)/components/theme-picker"
import { FONTS } from "@/app/(create)/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import { MainMenu } from "./main-menu"
export function Customizer() {
const [params] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
@@ -35,18 +34,11 @@ export function Customizer() {
return (
<div
className="no-scrollbar -mx-2.5 flex flex-col gap-4 overflow-y-auto rounded-2xl border p-4 md:mx-0 md:h-[calc(100svh---spacing(12))] md:w-72"
className="flex flex-col gap-4 rounded-2xl border p-4 md:h-[calc(100svh---spacing(12))] md:w-64"
ref={anchorRef}
>
<div className="flex items-center gap-2">
<Button
variant="secondary"
render={<Link href="/" />}
nativeButton={false}
role="link"
>
Exit
</Button>
<MainMenu />
<div className="ml-auto">
<ModeSwitcher />
</div>

View File

@@ -34,21 +34,16 @@ export function DesignSystemProvider({
const body = document.body
// Update style class in place (remove old, add new).
// Remove old style/base-color classes in a single pass, then add new ones. (js-combine-iterations)
body.classList.forEach((className) => {
if (className.startsWith("style-")) {
if (
className.startsWith("style-") ||
className.startsWith("base-color-")
) {
body.classList.remove(className)
}
})
body.classList.add(`style-${style}`)
// Update base color class in place.
body.classList.forEach((className) => {
if (className.startsWith("base-color-")) {
body.classList.remove(className)
}
})
body.classList.add(`base-color-${baseColor}`)
body.classList.add(`style-${style}`, `base-color-${baseColor}`)
// Update font.
const selectedFont = FONTS.find((f) => f.value === font)

View File

@@ -0,0 +1,78 @@
"use client"
import Script from "next/script"
import { Button } from "@/examples/base/ui/button"
import { Redo02Icon, Undo02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { useHistory } from "@/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="hidden items-center gap-1 sm:flex">
<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

@@ -1,51 +0,0 @@
"use client"
import { ArrowLeft02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
import { useHistory } from "@/app/(create)/hooks/use-history"
export function HistoryNavigation() {
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
return (
<div className="hidden items-center gap-1 sm:flex">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon-sm"
className="rounded-lg"
disabled={!canGoBack}
onClick={goBack}
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
<span className="sr-only">Undo</span>
</Button>
</TooltipTrigger>
<TooltipContent>Undo</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon-sm"
className="rounded-lg"
disabled={!canGoForward}
onClick={goForward}
>
<HugeiconsIcon icon={ArrowRight02Icon} className="size-4" />
<span className="sr-only">Redo</span>
</Button>
</TooltipTrigger>
<TooltipContent>Redo</TooltipContent>
</Tooltip>
</div>
)
}

View File

@@ -2,16 +2,11 @@
import * as React from "react"
import Link from "next/link"
import { ChevronRightIcon } from "lucide-react"
import { type RegistryItem } from "shadcn/schema"
import { cn } from "@/lib/utils"
import { type Base } from "@/registry/bases"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/registry/new-york-v4/ui/collapsible"
} from "@/examples/base/ui/collapsible"
import {
Sidebar,
SidebarContent,
@@ -20,7 +15,12 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/registry/new-york-v4/ui/sidebar"
} from "@/examples/base/ui/sidebar"
import { ChevronRightIcon } from "lucide-react"
import { type RegistryItem } from "shadcn/schema"
import { cn } from "@/lib/utils"
import { type Base } from "@/registry/bases"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import { groupItemsByType } from "@/app/(create)/lib/utils"
@@ -48,7 +48,7 @@ export function ItemExplorer({
return (
<Sidebar
className="sticky z-30 hidden h-[calc(100svh-var(--header-height)-2rem)] overscroll-none bg-transparent xl:flex"
className="sticky z-30 hidden h-full overscroll-none bg-transparent xl:flex"
collapsible="none"
>
<SidebarContent className="no-scrollbar -mx-1 overflow-x-hidden">

View File

@@ -1,198 +0,0 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { Search01Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { type RegistryItem } from "shadcn/schema"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Combobox,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
} from "@/registry/new-york-v4/ui/combobox"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import { groupItemsByType } from "@/app/(create)/lib/utils"
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"
const cachedGroupedItems = React.cache(
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {
return groupItemsByType(items)
}
)
export function ItemPicker({
itemsByBase,
}: {
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
}) {
const [open, setOpen] = React.useState(false)
const [params, setParams] = useDesignSystemSearchParams()
const items = React.useMemo(
() => itemsByBase[params.base] ?? [],
[itemsByBase, params.base]
)
const groupedItems = React.useMemo(() => cachedGroupedItems(items), [items])
const currentItem = React.useMemo(
() => items.find((item) => item.name === params.item) ?? null,
[items, params.item]
)
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === "k" || e.key === "p") && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen((open) => !open)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [])
const handleSelect = React.useCallback(
(item: Pick<RegistryItem, "name" | "title" | "type">) => {
setParams({ item: item.name })
setOpen(false)
},
[setParams]
)
const comboboxValue = React.useMemo(() => {
return currentItem ?? null
}, [currentItem])
return (
<Combobox
autoHighlight
items={groupedItems}
value={comboboxValue}
onValueChange={(value) => {
if (value) {
handleSelect(value)
}
}}
open={open}
onOpenChange={setOpen}
itemToStringValue={(item) => {
if (!item) {
return ""
}
// Handle both groups and items.
if ("items" in item) {
return item.title ?? ""
}
return item.title ?? item.name ?? ""
}}
>
<ComboboxTrigger
render={
<Button
variant="outline"
aria-label="Select item"
size="sm"
className="data-popup-open:bg-muted dark:data-popup-open:bg-muted/50 bg-muted/50 sm:bg-background md:dark:bg-background border-foreground/10 dark:bg-muted/50 h-[calc(--spacing(13.5))] w-full flex-1 touch-manipulation justify-between gap-2 rounded-xl pr-4! pl-2.5 text-left shadow-none select-none *:data-[slot=combobox-trigger-icon]:hidden sm:h-8 sm:rounded-lg sm:pr-2!"
/>
}
>
<ComboboxValue>
{(value) => (
<>
<div className="flex flex-col justify-start text-left sm:hidden">
<div className="text-muted-foreground text-xs font-normal">
Preview
</div>
<div className="text-foreground text-sm font-medium">
{value?.title || "Not Found"}
</div>
</div>
<div className="text-foreground hidden flex-1 text-sm sm:flex">
{value?.title || "Not Found"}
</div>
</>
)}
</ComboboxValue>
<HugeiconsIcon icon={Search01Icon} />
</ComboboxTrigger>
<ComboboxContent
className="ring-foreground/10 min-w-[calc(var(--available-width)---spacing(4))] translate-x-2 animate-none rounded-xl border-0 ring-1 data-open:animate-none sm:min-w-[calc(var(--anchor-width)+--spacing(7))] sm:translate-x-0 xl:w-96"
side="bottom"
align="center"
>
<ComboboxInput
showTrigger={false}
placeholder="Search"
className="bg-muted h-8 rounded-lg shadow-none has-focus-visible:border-inherit! has-focus-visible:ring-0! pointer-coarse:hidden"
/>
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList className="no-scrollbar scroll-my-1 pb-1">
{(group) => (
<ComboboxGroup key={group.type} items={group.items}>
<ComboboxLabel>{group.title}</ComboboxLabel>
<ComboboxCollection>
{(item) => (
<ComboboxItem
key={item.name}
value={item}
className="group/combobox-item rounded-lg pointer-coarse:py-2.5 pointer-coarse:pl-3 pointer-coarse:text-base"
>
{item.title}
<span className="text-muted-foreground ml-auto text-xs opacity-0 group-data-[selected=true]/combobox-item:opacity-100">
{group.title}
</span>
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
<div
data-open={open}
className="fixed inset-0 z-50 hidden bg-transparent data-[open=true]:block"
onClick={() => setOpen(false)}
/>
</Combobox>
)
}
export function ItemPickerScript() {
return (
<Script
id="design-system-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward Cmd/Ctrl + K and Cmd/Ctrl + P
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

@@ -1,5 +1,10 @@
"use client"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/examples/base/ui/tooltip"
import {
SquareLock01Icon,
SquareUnlock01Icon,
@@ -7,11 +12,6 @@ import {
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/lib/utils"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
import { useLocks, type LockableParam } from "@/app/(create)/hooks/use-locks"
export function LockButton({
@@ -25,26 +25,22 @@ export function LockButton({
const locked = isLocked(param)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => toggleLock(param)}
data-locked={locked}
className={cn(
"flex size-4 cursor-pointer items-center justify-center rounded opacity-0 transition-opacity group-focus-within/picker:opacity-100 group-hover/picker:opacity-100 focus:opacity-100 data-[locked=true]:opacity-100 pointer-coarse:hidden",
className
)}
aria-label={locked ? "Unlock" : "Lock"}
>
<HugeiconsIcon
icon={locked ? SquareLock01Icon : SquareUnlock01Icon}
strokeWidth={2}
className="text-foreground size-5"
/>
</button>
</TooltipTrigger>
<TooltipContent>{locked ? "Unlock" : "Lock"}</TooltipContent>
</Tooltip>
<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 transition-opacity group-focus-within/picker:opacity-100 group-hover/picker:opacity-100 focus:opacity-100 data-[locked=true]:opacity-100 pointer-coarse:hidden",
className
)}
>
<HugeiconsIcon
icon={locked ? SquareLock01Icon : SquareUnlock01Icon}
strokeWidth={2}
className="text-foreground size-5"
/>
</button>
)
}

View File

@@ -0,0 +1,85 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { Button } from "@/examples/base/ui/button"
import { ButtonGroup } from "@/examples/base/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/examples/base/ui/dropdown-menu"
import { ChevronDownIcon } from "lucide-react"
import { useHistory } from "@/app/(create)/hooks/use-history"
import { useRandom } from "@/app/(create)/hooks/use-random"
import { useReset } from "@/app/(create)/hooks/use-reset"
import { useThemeToggle } from "@/app/(create)/hooks/use-theme-toggle"
const APPLE_PLATFORM_REGEX = /Mac|iPhone|iPad|iPod/
export function MainMenu() {
const [isMac, setIsMac] = React.useState(false)
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
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 (
<ButtonGroup>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="secondary" id="menu-button" />}
>
Menu
<ChevronDownIcon data-icon="inline-end" />
</DropdownMenuTrigger>
<DropdownMenuContent className="animate-none!">
<DropdownMenuGroup>
<DropdownMenuItem onClick={goBack} disabled={!canGoBack}>
Undo{" "}
<DropdownMenuShortcut>
{isMac ? "⌘Z" : "Ctrl+Z"}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={goForward} disabled={!canGoForward}>
Redo{" "}
<DropdownMenuShortcut>
{isMac ? "⇧⌘Z" : "Ctrl+Shift+Z"}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={randomize}>
Shuffle <DropdownMenuShortcut>R</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={toggleTheme}>
Toggle Theme <DropdownMenuShortcut>D</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => setShowResetDialog(true)}>
Reset
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem render={<Link href="/" />}>Exit</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
)
}

View File

@@ -0,0 +1,102 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { Button } from "@/examples/base/ui/button"
import { Kbd } from "@/examples/base/ui/kbd"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/examples/base/ui/tooltip"
import { cn } from "@/lib/utils"
import { useThemeToggle } from "@/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 (
<Tooltip>
<TooltipTrigger
render={
<Button
variant={variant}
size="icon"
className={cn("group/toggle extend-touch-target size-8", 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>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2 pr-1">
Toggle Mode <Kbd>D</Kbd>
</TooltipContent>
</Tooltip>
)
}
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

@@ -1,7 +1,9 @@
import { Separator } from "@/registry/new-york-v4/ui/separator"
import { Separator } from "@/examples/base/ui/separator"
import { type RegistryItem } from "shadcn/schema"
import { ActionMenu } from "@/app/(create)/components/action-menu"
import { CopyPreset } from "@/app/(create)/components/copy-preset"
import { HistoryNavigation } from "@/app/(create)/components/history-navigation"
import { ItemPicker } from "@/app/(create)/components/item-picker"
import { HistoryButtons } from "@/app/(create)/components/history-buttons"
import { ProjectForm } from "@/app/(create)/components/project-form"
import { ShareButton } from "@/app/(create)/components/share-button"
import { V0Button } from "@/app/(create)/components/v0-button"
@@ -9,30 +11,23 @@ import { V0Button } from "@/app/(create)/components/v0-button"
export function PageHeader({
itemsByBase,
}: {
itemsByBase: Record<
string,
{ name: string; title: string | undefined; type: string }[]
>
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
}) {
return (
<header className="sticky top-0 z-50 w-full border-b">
<div className="px-4">
<div className="flex h-(--header-height) items-center gap-4 **:data-[slot=separator]:h-4!">
<div className="px-2">
<div className="flex h-12 items-center gap-4 **:data-[slot=separator]:h-4! **:data-[slot=separator]:self-center">
<div className="item-center flex w-1/3 gap-2">
<HistoryNavigation />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:self-center"
/>
<HistoryButtons />
<Separator orientation="vertical" />
<ActionMenu itemsByBase={itemsByBase} />
</div>
<div className="ml-auto flex items-center justify-end gap-2">
<CopyPreset />
</div>
<div className="flex flex-1 items-center gap-2">
<ItemPicker itemsByBase={itemsByBase} />
</div>
<div className="ml-auto flex w-1/3 items-center gap-2 sm:ml-0 md:justify-end">
<Separator orientation="vertical" />
<ShareButton />
<V0Button />
<Separator orientation="vertical" className="mr-0 -ml-2 sm:ml-0" />
<Separator orientation="vertical" />
<ProjectForm />
</div>
</div>

View File

@@ -19,7 +19,7 @@ function PickerTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
<MenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
className={cn(
"hover:bg-muted data-popup-open:bg-muted border-foreground/10 bg-muted/50 relative w-[160px] shrink-0 touch-manipulation rounded-xl border border-none p-2 select-none disabled:opacity-50 md:w-full md:rounded-lg",
"hover:bg-muted data-popup-open:bg-muted bg-muted/50 relative w-full shrink-0 touch-manipulation rounded-xl p-2 ring ring-black/10 select-none disabled:opacity-50 md:w-full md:rounded-lg",
className
)}
{...props}
@@ -53,7 +53,7 @@ function PickerContent({
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 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 bg-popover text-popover-foreground cn-menu-target ring-foreground/10 no-scrollbar z-50 max-h-(--available-height) w-[calc(var(--available-width)-(--spacing(3.5)))] min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border-0 p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden md:w-52",
"bg-popover text-popover-foreground cn-menu-target ring-foreground/10 no-scrollbar z-50 max-h-(--available-height) w-[calc(var(--available-width)-(--spacing(3.5)))] min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border-0 p-1 shadow-md ring-1 outline-none data-closed:overflow-hidden md:w-52",
className
)}
{...props}

View File

@@ -1,11 +1,14 @@
"use client"
import * as React from "react"
import { type PanelImperativeHandle } from "react-resizable-panels"
import { Badge } from "@/examples/base/ui/badge"
import { DARK_MODE_FORWARD_TYPE } from "@/components/mode-switcher"
import { Badge } from "@/registry/new-york-v4/ui/badge"
import { CMD_K_FORWARD_TYPE } from "@/app/(create)/components/item-picker"
import { CMD_K_FORWARD_TYPE } from "@/app/(create)/components/action-menu"
import {
REDO_FORWARD_TYPE,
UNDO_FORWARD_TYPE,
} from "@/app/(create)/components/history-buttons"
import { DARK_MODE_FORWARD_TYPE } from "@/app/(create)/components/mode-switcher"
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(create)/components/random-button"
import { sendToIframe } from "@/app/(create)/hooks/use-iframe-sync"
import {
@@ -13,17 +16,68 @@ import {
useDesignSystemSearchParams,
} from "@/app/(create)/lib/search-params"
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
// Hoisted — only uses module-level constants, no component state. (rendering-hoist-jsx)
function handleMessage(event: MessageEvent) {
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 === 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 === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
export function Preview() {
const [params] = useDesignSystemSearchParams()
const iframeRef = React.useRef<HTMLIFrameElement>(null)
const resizablePanelRef = React.useRef<PanelImperativeHandle>(null)
// Sync resizable panel with URL param changes.
React.useEffect(() => {
if (resizablePanelRef.current && params.size) {
resizablePanelRef.current.resize(params.size)
}
}, [params.size])
React.useEffect(() => {
const iframe = iframeRef.current
@@ -45,44 +99,6 @@ export function Preview() {
}
}, [params])
const handleMessage = (event: MessageEvent) => {
if (event.data.type === CMD_K_FORWARD_TYPE) {
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
const key = event.data.key || "k"
const syntheticEvent = new KeyboardEvent("keydown", {
key,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
document.dispatchEvent(syntheticEvent)
}
if (event.data.type === RANDOMIZE_FORWARD_TYPE) {
const key = event.data.key || "r"
const syntheticEvent = new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
})
document.dispatchEvent(syntheticEvent)
}
if (event.data.type === DARK_MODE_FORWARD_TYPE) {
const key = event.data.key || "d"
const syntheticEvent = new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
})
document.dispatchEvent(syntheticEvent)
}
}
React.useEffect(() => {
window.addEventListener("message", handleMessage)
return () => {

View File

@@ -1,17 +1,7 @@
"use client"
import * as React from "react"
import {
ComputerTerminal01Icon,
Copy01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { useConfig } from "@/hooks/use-config"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { BASES } from "@/registry/config"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Button } from "@/examples/base/ui/button"
import {
Dialog,
DialogContent,
@@ -20,39 +10,40 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/registry/new-york-v4/ui/dialog"
} from "@/examples/base/ui/dialog"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/registry/new-york-v4/ui/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/new-york-v4/ui/select"
import { Switch } from "@/registry/new-york-v4/ui/switch"
FieldTitle,
} from "@/examples/base/ui/field"
import { Switch } from "@/examples/base/ui/switch"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/registry/new-york-v4/ui/tabs"
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
} from "@/examples/base/ui/tabs"
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
const TEMPLATES = [
{ value: "next", title: "Next.js" },
{ value: "start", title: "TanStack Start" },
{ value: "react-router", title: "React Router" },
{ value: "vite", title: "Vite" },
{ value: "next-monorepo", title: "Next.js (Monorepo)" },
] as const
import { cn } from "@/lib/utils"
import { useConfig } from "@/hooks/use-config"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import {
ALL_TEMPLATES,
getFramework,
getTemplateValue,
NO_MONOREPO_FRAMEWORKS,
TEMPLATES,
} from "@/app/(create)/components/template-picker"
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(create)/lib/search-params"
export function ProjectForm() {
const [open, setOpen] = React.useState(false)
@@ -60,20 +51,24 @@ export function ProjectForm() {
const presetCode = usePresetCode()
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
const [hasCopiedLaravel, setHasCopiedLaravel] = React.useState(false)
const packageManager = config.packageManager || "pnpm"
const isLaravel = getFramework(params.template ?? "next") === "laravel"
const commands = React.useMemo(() => {
const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"
const isLocalDev = origin.includes("localhost")
const presetFlag = ` --preset ${presetCode}`
const framework = getFramework(params.template ?? "next")
const isMonorepo = params.template?.endsWith("-monorepo") ?? false
// Laravel doesn't use --template since it has its own scaffolding.
const templateFlag =
params.template && params.template !== "existing"
? ` --template ${params.template}`
: ""
const baseFlag = params.base ? ` --base ${params.base}` : ""
params.template && !isLaravel ? ` --template ${framework}` : ""
const monorepoFlag = isMonorepo ? " --monorepo" : ""
const rtlFlag = params.rtl ? " --rtl" : ""
const flags = `${presetFlag}${templateFlag}${baseFlag}${rtlFlag}`
const flags = `${presetFlag}${templateFlag}${monorepoFlag}${rtlFlag}`
return isLocalDev
? {
@@ -88,7 +83,7 @@ export function ProjectForm() {
yarn: `yarn dlx shadcn@latest init${flags}`,
bun: `bunx --bun shadcn@latest init${flags}`,
}
}, [presetCode, params.base, params.rtl, params.template])
}, [presetCode, params.rtl, params.template, isLaravel])
const command = commands[packageManager]
@@ -99,6 +94,13 @@ export function ProjectForm() {
}
}, [hasCopied])
React.useEffect(() => {
if (hasCopiedLaravel) {
const timer = setTimeout(() => setHasCopiedLaravel(false), 2000)
return () => clearTimeout(timer)
}
}, [hasCopiedLaravel])
const handleCopy = React.useCallback(() => {
const properties: Record<string, string> = {
command,
@@ -113,49 +115,20 @@ export function ProjectForm() {
setHasCopied(true)
}, [command, params.template])
const selectedTemplate = TEMPLATES.find(
(template) => template.value === params.template
)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" className="hidden h-[31px] rounded-lg pl-2 md:flex">
Create Project
</Button>
</DialogTrigger>
<DialogContent className="dialog-ring min-w-0 overflow-hidden rounded-xl sm:max-w-lg">
<DialogTrigger render={<Button />}>Create Project</DialogTrigger>
<DialogContent className="min-w-0 sm:max-w-md">
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription className="text-balance">
<DialogDescription>
Configure your project to use shadcn/ui.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="template">Template</FieldLabel>
<Select
value={params.template ?? ""}
onValueChange={(value) => {
setParams({
template: (value || null) as typeof params.template,
})
}}
>
<SelectTrigger id="template">
<SelectValue placeholder="Select a template" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="existing">Existing project</SelectItem>
{TEMPLATES.map((template) => (
<SelectItem key={template.value} value={template.value}>
{template.title}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldLabel>Template</FieldLabel>
<TemplateGrid params={params} setParams={setParams} />
<FieldDescription>
See the{" "}
<a
@@ -169,62 +142,93 @@ export function ProjectForm() {
for more templates and frameworks.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="base">Component Library</FieldLabel>
<Select
value={params.base}
onValueChange={(value) => {
setParams({
base: value as "radix" | "base",
})
}}
>
<SelectTrigger id="base">
<SelectValue placeholder="Select a component library" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{BASES.map((base) => (
<SelectItem key={base.name} value={base.name}>
{base.title}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal" className="items-center">
<FieldContent className="gap-0.5">
<FieldLabel htmlFor="rtl">Enable RTL</FieldLabel>
<FieldDescription className="text-balance">
Enable right-to-left support.
{selectedTemplate && (
<>
{" "}
See the{" "}
<a
href={`/docs/rtl/${params.template === "next-monorepo" ? "next" : params.template}`}
className="text-foreground underline"
target="_blank"
rel="noopener noreferrer"
>
RTL setup guide
</a>{" "}
for {selectedTemplate.title}.
</>
)}
</FieldDescription>
</FieldContent>
<Switch
id="rtl"
checked={params.rtl}
onCheckedChange={(checked) =>
setParams({ rtl: checked === true })
}
/>
</Field>
{!isLaravel && (
<FieldLabel htmlFor="monorepo">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>Monorepo</FieldTitle>
<FieldDescription>
Use a Turborepo monorepo with a shared UI package.
</FieldDescription>
</FieldContent>
<Switch
id="monorepo"
checked={params.template?.endsWith("-monorepo") ?? false}
onCheckedChange={(checked) => {
const framework = getFramework(params.template ?? "next")
setParams({
template: getTemplateValue(
framework,
checked === true
) as typeof params.template,
})
}}
/>
</Field>
</FieldLabel>
)}
<FieldLabel htmlFor="rtl">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>Enable RTL</FieldTitle>
<FieldDescription>
Add right-to-left support for your project.
</FieldDescription>
</FieldContent>
<Switch
id="rtl"
checked={params.rtl}
onCheckedChange={(checked) =>
setParams({ rtl: checked === true })
}
/>
</Field>
</FieldLabel>
</FieldGroup>
<DialogFooter className="bg-muted/30 -mx-6 mt-2 -mb-6 flex min-w-0 flex-col gap-3 border-t p-6 sm:flex-col">
{isLaravel && (
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-sm">
<a
href="https://laravel.com/docs#creating-a-laravel-project"
className="text-foreground underline"
target="_blank"
rel="noopener noreferrer"
>
Create a new Laravel project
</a>
, then run the following command.
</p>
<div className="bg-surface min-w-0 overflow-hidden rounded-lg border">
<div className="flex items-center py-1.5 pr-1.5 pl-3">
<div className="no-scrollbar min-w-0 flex-1 overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
laravel new example-app
</code>
</div>
<Button
size="icon-sm"
variant="ghost"
className="ml-2 size-7 shrink-0"
onClick={() => {
copyToClipboardWithMeta("laravel new example-app", {
name: "copy_npm_command",
properties: { command: "laravel new example-app" },
})
setHasCopiedLaravel(true)
}}
>
{hasCopiedLaravel ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
</div>
</div>
)}
<Tabs
value={packageManager}
onValueChange={(value) => {
@@ -245,7 +249,7 @@ export function ProjectForm() {
<Button
size="icon-sm"
variant="ghost"
className="ml-auto size-7 rounded-lg"
className="ml-auto size-7"
onClick={handleCopy}
>
{hasCopied ? (
@@ -270,11 +274,7 @@ export function ProjectForm() {
)
})}
</Tabs>
<Button
size="sm"
onClick={handleCopy}
className="h-9 w-full rounded-lg"
>
<Button onClick={handleCopy} className="h-9 w-full">
Copy Command
</Button>
</DialogFooter>
@@ -282,3 +282,57 @@ export function ProjectForm() {
</Dialog>
)
}
function TemplateGrid({
params,
setParams,
}: {
params: DesignSystemSearchParams
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
}) {
const isMonorepo = params.template?.endsWith("-monorepo") ?? false
const framework = getFramework(params.template ?? "next")
return (
<div className="flex flex-col gap-2">
{TEMPLATES.map((row, rowIndex) => (
<div
key={rowIndex}
className="grid gap-2"
style={{ gridTemplateColumns: `repeat(${row.length}, 1fr)` }}
>
{row.map((template) => (
<button
key={template.value}
type="button"
onClick={() =>
setParams({
template: getTemplateValue(
template.value,
isMonorepo
) as typeof params.template,
})
}
className={cn(
"flex flex-col items-center gap-2 rounded-lg border p-3 text-sm transition-colors",
framework === template.value
? "border-foreground bg-muted"
: "border-border hover:bg-muted/50"
)}
>
<div
className="text-foreground *:[svg]:text-foreground! size-5 *:[svg]:size-5"
dangerouslySetInnerHTML={{
__html: template.logo,
}}
/>
<span className="text-foreground text-xs font-medium">
{template.title}
</span>
</button>
))}
</div>
))}
</div>
)
}

View File

@@ -6,121 +6,18 @@ import { Button } from "@/examples/base/ui/button"
import { DiceFaces05Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import {
BASE_COLORS,
getThemesForBaseColor,
iconLibraries,
MENU_ACCENTS,
MENU_COLORS,
RADII,
STYLES,
} from "@/registry/config"
import { useLocks } from "@/app/(create)/hooks/use-locks"
import { FONTS } from "@/app/(create)/lib/fonts"
import {
applyBias,
RANDOMIZE_BIASES,
type RandomizeContext,
} from "@/app/(create)/lib/randomize-biases"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import { useRandom } from "@/app/(create)/hooks/use-random"
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
function randomItem<T>(array: readonly T[]): T {
return array[Math.floor(Math.random() * array.length)]
}
export function RandomButton() {
const { locks } = useLocks()
const [params, setParams] = useDesignSystemSearchParams()
const handleRandomize = React.useCallback(() => {
const selectedStyle = locks.has("style")
? params.style
: randomItem(STYLES).name
// Build context for bias application.
const context: RandomizeContext = {
style: selectedStyle,
}
const availableBaseColors = applyBias(
BASE_COLORS,
context,
RANDOMIZE_BIASES.baseColors
)
const baseColor = locks.has("baseColor")
? params.baseColor
: randomItem(availableBaseColors).name
context.baseColor = baseColor
const availableThemes = getThemesForBaseColor(baseColor)
const availableFonts = applyBias(FONTS, context, RANDOMIZE_BIASES.fonts)
const availableRadii = applyBias(RADII, context, RANDOMIZE_BIASES.radius)
const selectedTheme = locks.has("theme")
? params.theme
: randomItem(availableThemes).name
const selectedFont = locks.has("font")
? params.font
: randomItem(availableFonts).value
const selectedRadius = locks.has("radius")
? params.radius
: randomItem(availableRadii).name
const selectedIconLibrary = locks.has("iconLibrary")
? params.iconLibrary
: randomItem(Object.values(iconLibraries)).name
const selectedMenuAccent = locks.has("menuAccent")
? params.menuAccent
: randomItem(MENU_ACCENTS).value
const selectedMenuColor = locks.has("menuColor")
? params.menuColor
: randomItem(MENU_COLORS).value
// Update context with selected values for potential future biases.
context.theme = selectedTheme
context.font = selectedFont
context.radius = selectedRadius
setParams({
style: selectedStyle,
baseColor,
theme: selectedTheme,
iconLibrary: selectedIconLibrary,
font: selectedFont,
menuAccent: selectedMenuAccent,
menuColor: selectedMenuColor,
radius: selectedRadius,
})
}, [setParams, locks, params])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
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()
handleRandomize()
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [handleRandomize])
const { randomize } = useRandom()
return (
<>
<React.Fragment>
<Button
variant="ghost"
size="sm"
onClick={handleRandomize}
onClick={randomize}
className="border-foreground/10 bg-muted/50 flex h-[calc(--spacing(13.5))] w-[140px] touch-manipulation justify-between rounded-xl border select-none focus-visible:border-transparent focus-visible:ring-1 sm:hidden"
>
<div className="flex flex-col justify-start text-left">
@@ -131,12 +28,28 @@ export function RandomButton() {
</Button>
<Button
variant="outline"
onClick={handleRandomize}
onClick={randomize}
className="hidden w-full sm:flex"
>
Shuffle
</Button>
</>
</React.Fragment>
)
}
export function RandomIconButton() {
const { randomize } = useRandom()
return (
<Button
variant="ghost"
size="icon-sm"
onClick={randomize}
aria-label="Randomize"
>
<HugeiconsIcon icon={DiceFaces05Icon} />
<span className="sr-only">Randomize</span>
</Button>
)
}

View File

@@ -16,38 +16,18 @@ import { Button } from "@/examples/base/ui/button"
import { Undo02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { DEFAULT_CONFIG } from "@/registry/config"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
import { useReset } from "@/app/(create)/hooks/use-reset"
export function ResetButton() {
const [params, setParams] = useDesignSystemSearchParams()
const [mobileOpen, setMobileOpen] = React.useState(false)
const [desktopOpen, setDesktopOpen] = React.useState(false)
const handleReset = React.useCallback(() => {
setParams({
base: params.base, // Keep the current base value.
style: DEFAULT_CONFIG.style,
baseColor: DEFAULT_CONFIG.baseColor,
theme: DEFAULT_CONFIG.theme,
iconLibrary: DEFAULT_CONFIG.iconLibrary,
font: DEFAULT_CONFIG.font,
menuAccent: DEFAULT_CONFIG.menuAccent,
menuColor: DEFAULT_CONFIG.menuColor,
radius: DEFAULT_CONFIG.radius,
template: DEFAULT_CONFIG.template,
item: "preview",
})
}, [setParams, params.base])
const { showResetDialog, setShowResetDialog, confirmReset } = useReset()
return (
<>
<AlertDialog open={mobileOpen} onOpenChange={setMobileOpen}>
<React.Fragment>
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="sm"
className="border-foreground/10 bg-muted/50 flex h-[calc(--spacing(13.5))] w-[140px] touch-manipulation justify-between rounded-xl border select-none focus-visible:border-transparent focus-visible:ring-1 sm:hidden"
>
<div className="flex flex-col justify-start text-left">
@@ -60,28 +40,6 @@ export function ResetButton() {
</Button>
}
/>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Reset to defaults?</AlertDialogTitle>
<AlertDialogDescription>
This will reset all customization options to their default values.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-lg">Cancel</AlertDialogCancel>
<AlertDialogAction
className="rounded-lg"
onClick={() => {
handleReset()
setMobileOpen(false)
}}
>
Reset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={desktopOpen} onOpenChange={setDesktopOpen}>
<AlertDialogTrigger
render={
<Button variant="outline" className="hidden w-full sm:flex">
@@ -97,19 +55,27 @@ export function ResetButton() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-lg">Cancel</AlertDialogCancel>
<AlertDialogAction
className="rounded-lg"
onClick={() => {
handleReset()
setDesktopOpen(false)
}}
>
Reset
</AlertDialogAction>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset}>Reset</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
</React.Fragment>
)
}
export function ResetIconButton() {
const { setShowResetDialog } = useReset()
return (
<Button
variant="ghost"
size="icon-sm"
aria-label="Reset to defaults"
onClick={() => setShowResetDialog(true)}
>
<HugeiconsIcon icon={Undo02Icon} />
<span className="sr-only">Reset</span>
</Button>
)
}

View File

@@ -1,11 +1,11 @@
"use client"
import * as React from "react"
import { Button } from "@/examples/base/ui/button"
import { Share03Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/registry/new-york-v4/ui/button"
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
@@ -37,16 +37,19 @@ export function ShareButton() {
}, [shareUrl])
return (
<Button
size="sm"
variant="outline"
className="rounded-lg shadow-none"
onClick={handleCopy}
>
<Button variant="outline" onClick={handleCopy}>
{hasCopied ? (
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
data-icon="inline-start"
/>
) : (
<HugeiconsIcon icon={Share03Icon} strokeWidth={2} />
<HugeiconsIcon
icon={Share03Icon}
strokeWidth={2}
data-icon="inline-start"
/>
)}
Share
</Button>

View File

@@ -10,24 +10,58 @@ import {
} from "@/app/(create)/components/picker"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
const TEMPLATES = [
{
value: "next",
title: "Next.js",
logo: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Next.js</title><path d="M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z"/></svg>',
},
{
value: "start",
title: "TanStack Start",
logo: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>TanStack</title><path d="M11.078.042c.316-.042.65-.014.97-.014 1.181 0 2.341.184 3.472.532a12.3 12.3 0 0 1 3.973 2.086 11.9 11.9 0 0 1 3.432 4.33c1.446 3.15 1.436 6.97-.046 10.107-.958 2.029-2.495 3.727-4.356 4.965-1.518 1.01-3.293 1.629-5.1 1.848-2.298.279-4.784-.129-6.85-1.188-3.88-1.99-6.518-5.994-6.57-10.382-.01-.846.003-1.697.17-2.534.273-1.365.748-2.683 1.463-3.88a12 12 0 0 1 2.966-3.36A12.3 12.3 0 0 1 9.357.3a12 12 0 0 1 1.255-.2l.133-.016zM7.064 19.99c-.535.057-1.098.154-1.557.454.103.025.222 0 .33 0 .258 0 .52-.01.778.002.647.028 1.32.131 1.945.303.8.22 1.505.65 2.275.942.813.307 1.622.402 2.484.402.435 0 .866-.001 1.287-.12-.22-.117-.534-.095-.778-.144a11 11 0 0 1-1.556-.416 12 12 0 0 1-1.093-.467l-.23-.108a15 15 0 0 0-1.012-.44c-.905-.343-1.908-.512-2.873-.408m.808-2.274c-1.059 0-2.13.187-3.083.667q-.346.177-.659.41c-.063.046-.175.106-.199.188s.061.151.11.204c.238-.127.464-.261.718-.357 1.64-.624 3.63-.493 5.268.078.817.285 1.569.712 2.365 1.046.89.374 1.798.616 2.753.74 1.127.147 2.412.028 3.442-.48.362-.179.865-.451 1.018-.847-.189.017-.36.098-.539.154a9 9 0 0 1-.868.222c-.994.2-2.052.24-3.053.06-.943-.17-1.82-.513-2.693-.873l-.111-.046-.223-.092-.112-.046a26 26 0 0 0-1.35-.527c-.89-.31-1.842-.5-2.784-.5M9.728 1.452c-1.27.28-2.407.826-3.502 1.514-.637.4-1.245.81-1.796 1.323-.82.765-1.447 1.695-1.993 2.666-.563 1-.924 2.166-1.098 3.297-.172 1.11-.2 2.277-.004 3.388.245 1.388.712 2.691 1.448 3.897.248-.116.424-.38.629-.557.414-.359.85-.691 1.317-.978a3.5 3.5 0 0 1 .539-.264c.07-.029.187-.055.22-.132.053-.124-.045-.34-.062-.468a7 7 0 0 1-.068-1.109 9.7 9.7 0 0 1 .61-3.177c.29-.76.73-1.45 1.254-2.069.177-.21.365-.405.56-.6.115-.114.258-.212.33-.359-.376 0-.751.108-1.108.218-.769.237-1.518.588-2.155 1.084-.291.226-.504.522-.779.76-.084.073-.235.17-.352.116-.176-.083-.149-.43-.169-.59-.078-.612.154-1.387.45-1.918.473-.852 1.348-1.58 2.376-1.555.444.011.833.166 1.257.266-.107-.153-.252-.264-.389-.39a5.4 5.4 0 0 0-1.107-.8c-.163-.085-.338-.136-.509-.2-.086-.03-.195-.074-.227-.17-.06-.177.26-.342.377-.417.453-.289 1.01-.527 1.556-.54.854-.021 1.688.452 2.04 1.258.123.284.16.583.184.885l.004.057.006.085.002.029.005.057.004.056c.268-.218.457-.54.718-.774.612-.547 1.45-.79 2.245-.544a2.97 2.97 0 0 1 1.71 1.378c.097.173.365.595.171.767-.152.134-.344.03-.504-.026a3 3 0 0 0-.372-.094l-.068-.014-.069-.013a3.9 3.9 0 0 0-1.377-.002c-.282.05-.557.15-.838.192v.06c.768.006 1.51.444 1.89 1.109.157.275.235.59.295.9.075.38.022.796-.082 1.168-.035.125-.098.336-.247.365-.106.02-.195-.085-.256-.155a4.6 4.6 0 0 0-.492-.522 20 20 0 0 0-1.467-1.14c-.267-.19-.56-.44-.868-.556.087.208.171.402.2.63.088.667-.192 1.296-.612 1.798a2.6 2.6 0 0 1-.426.427c-.067.05-.151.114-.24.1-.277-.044-.31-.463-.353-.677-.144-.726-.086-1.447.114-2.158-.178.09-.307.287-.418.45a5.3 5.3 0 0 0-.612 1.138c-.61 1.617-.604 3.51.186 5.066.088.174.221.15.395.15h.157a3 3 0 0 1 .472.018c.08.01.193 0 .257.06.077.072.036.194.018.282-.05.246-.066.469-.066.72.328-.051.419-.576.535-.84.131-.298.265-.597.387-.9.06-.148.14-.314.119-.479-.024-.185-.157-.381-.25-.54-.177-.298-.378-.606-.508-.929-.104-.258-.007-.58.286-.672.161-.05.334.049.439.166.22.244.363.609.523.896l1.249 2.248q.159.286.32.57c.043.074.086.188.173.219.077.028.182-.012.26-.027.198-.04.398-.083.598-.12.24-.043.605-.035.778-.222-.253-.08-.545-.075-.808-.057-.158.01-.333.067-.479-.025-.216-.137-.36-.455-.492-.667-.326-.525-.633-1.057-.945-1.59l-.05-.084-.1-.17q-.075-.126-.149-.255c-.037-.066-.092-.153-.039-.227.056-.076.179-.08.29-.081h.021q.066.001.117-.004a10 10 0 0 1 1.347-.107c-.035-.122-.135-.26-.103-.39.071-.292.49-.383.686-.174.131.14.207.334.292.504.113.223.24.44.361.66.211.383.441.757.658 1.138l.055.094.028.047c.093.156.187.314.238.489-.753-.035-1.318-.909-1.646-1.499-.027.095.016.179.05.27q.103.282.262.54c.152.244.326.495.556.673.408.315.945.317 1.436.283.315-.022.708-.165 1.018-.068s.434.438.25.7c-.138.196-.321.27-.55.3.162.346.373.667.527 1.02.064.146.13.37.283.448.102.051.248.003.358 0-.11-.292-.317-.54-.419-.839.31.015.61.176.898.28.567.202 1.128.424 1.687.648l.258.104c.23.092.462.183.689.283.083.037.198.123.29.07.074-.043.123-.146.169-.215a10.3 10.3 0 0 0 1.393-3.208c.75-2.989.106-6.287-1.695-8.783-.692-.96-1.562-1.789-2.522-2.476-2.401-1.718-5.551-2.407-8.44-1.768m4.908 14.904c-.636.166-1.292.317-1.945.401.086.293.296.577.45.84.059.101.122.237.24.281.132.05.292-.03.417-.072-.058-.158-.155-.3-.235-.45-.033-.06-.084-.133-.056-.206.05-.137.263-.13.381-.153.31-.063.617-.142.928-.204.114-.023.274-.085.389-.047.086.03.138.1.187.174l.022.033q.043.07.097.122c.125.113.313.13.472.162-.097-.219-.259-.41-.362-.63-.06-.127-.11-.315-.242-.388-.182-.102-.557.089-.743.137m-4.01-1.457c-.03.38-.147.689-.33 1.019.21.026.423.036.629.087.154.038.296.11.449.153-.082-.224-.233-.423-.35-.63-.12-.208-.226-.462-.398-.63"/></svg>',
},
{
value: "vite",
title: "Vite",
logo: '<svg xmlns="http://www.w3.org/2000/svg" width="410" height="404" fill="none" viewBox="0 0 410 404"><path fill="var(--foreground)" d="m399.641 59.525-183.998 329.02c-3.799 6.793-13.559 6.833-17.415.073L10.582 59.556C6.38 52.19 12.68 43.266 21.028 44.76l184.195 32.923c1.175.21 2.378.208 3.553-.006l180.343-32.87c8.32-1.517 14.649 7.337 10.522 14.719"/><path fill="var(--background)" d="M292.965 1.574 156.801 28.255a5 5 0 0 0-4.03 4.611l-8.376 141.464c-.197 3.332 2.863 5.918 6.115 5.168l37.91-8.749c3.547-.818 6.752 2.306 6.023 5.873l-11.263 55.153c-.758 3.712 2.727 6.886 6.352 5.785l23.415-7.114c3.63-1.102 7.118 2.081 6.35 5.796l-17.899 86.633c-1.12 5.419 6.088 8.374 9.094 3.728l2.008-3.103 110.954-221.428c1.858-3.707-1.346-7.935-5.418-7.15l-39.022 7.532c-3.667.707-6.787-2.708-5.752-6.296l25.469-88.291c1.036-3.594-2.095-7.012-5.766-6.293"/></svg>',
},
const NEXT_LOGO =
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Next.js</title><path d="M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z"/></svg>'
const START_LOGO =
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>TanStack</title><path d="M11.078.042c.316-.042.65-.014.97-.014 1.181 0 2.341.184 3.472.532a12.3 12.3 0 0 1 3.973 2.086 11.9 11.9 0 0 1 3.432 4.33c1.446 3.15 1.436 6.97-.046 10.107-.958 2.029-2.495 3.727-4.356 4.965-1.518 1.01-3.293 1.629-5.1 1.848-2.298.279-4.784-.129-6.85-1.188-3.88-1.99-6.518-5.994-6.57-10.382-.01-.846.003-1.697.17-2.534.273-1.365.748-2.683 1.463-3.88a12 12 0 0 1 2.966-3.36A12.3 12.3 0 0 1 9.357.3a12 12 0 0 1 1.255-.2l.133-.016zM7.064 19.99c-.535.057-1.098.154-1.557.454.103.025.222 0 .33 0 .258 0 .52-.01.778.002.647.028 1.32.131 1.945.303.8.22 1.505.65 2.275.942.813.307 1.622.402 2.484.402.435 0 .866-.001 1.287-.12-.22-.117-.534-.095-.778-.144a11 11 0 0 1-1.556-.416 12 12 0 0 1-1.093-.467l-.23-.108a15 15 0 0 0-1.012-.44c-.905-.343-1.908-.512-2.873-.408m.808-2.274c-1.059 0-2.13.187-3.083.667q-.346.177-.659.41c-.063.046-.175.106-.199.188s.061.151.11.204c.238-.127.464-.261.718-.357 1.64-.624 3.63-.493 5.268.078.817.285 1.569.712 2.365 1.046.89.374 1.798.616 2.753.74 1.127.147 2.412.028 3.442-.48.362-.179.865-.451 1.018-.847-.189.017-.36.098-.539.154a9 9 0 0 1-.868.222c-.994.2-2.052.24-3.053.06-.943-.17-1.82-.513-2.693-.873l-.111-.046-.223-.092-.112-.046a26 26 0 0 0-1.35-.527c-.89-.31-1.842-.5-2.784-.5M9.728 1.452c-1.27.28-2.407.826-3.502 1.514-.637.4-1.245.81-1.796 1.323-.82.765-1.447 1.695-1.993 2.666-.563 1-.924 2.166-1.098 3.297-.172 1.11-.2 2.277-.004 3.388.245 1.388.712 2.691 1.448 3.897.248-.116.424-.38.629-.557.414-.359.85-.691 1.317-.978a3.5 3.5 0 0 1 .539-.264c.07-.029.187-.055.22-.132.053-.124-.045-.34-.062-.468a7 7 0 0 1-.068-1.109 9.7 9.7 0 0 1 .61-3.177c.29-.76.73-1.45 1.254-2.069.177-.21.365-.405.56-.6.115-.114.258-.212.33-.359-.376 0-.751.108-1.108.218-.769.237-1.518.588-2.155 1.084-.291.226-.504.522-.779.76-.084.073-.235.17-.352.116-.176-.083-.149-.43-.169-.59-.078-.612.154-1.387.45-1.918.473-.852 1.348-1.58 2.376-1.555.444.011.833.166 1.257.266-.107-.153-.252-.264-.389-.39a5.4 5.4 0 0 0-1.107-.8c-.163-.085-.338-.136-.509-.2-.086-.03-.195-.074-.227-.17-.06-.177.26-.342.377-.417.453-.289 1.01-.527 1.556-.54.854-.021 1.688.452 2.04 1.258.123.284.16.583.184.885l.004.057.006.085.002.029.005.057.004.056c.268-.218.457-.54.718-.774.612-.547 1.45-.79 2.245-.544a2.97 2.97 0 0 1 1.71 1.378c.097.173.365.595.171.767-.152.134-.344.03-.504-.026a3 3 0 0 0-.372-.094l-.068-.014-.069-.013a3.9 3.9 0 0 0-1.377-.002c-.282.05-.557.15-.838.192v.06c.768.006 1.51.444 1.89 1.109.157.275.235.59.295.9.075.38.022.796-.082 1.168-.035.125-.098.336-.247.365-.106.02-.195-.085-.256-.155a4.6 4.6 0 0 0-.492-.522 20 20 0 0 0-1.467-1.14c-.267-.19-.56-.44-.868-.556.087.208.171.402.2.63.088.667-.192 1.296-.612 1.798a2.6 2.6 0 0 1-.426.427c-.067.05-.151.114-.24.1-.277-.044-.31-.463-.353-.677-.144-.726-.086-1.447.114-2.158-.178.09-.307.287-.418.45a5.3 5.3 0 0 0-.612 1.138c-.61 1.617-.604 3.51.186 5.066.088.174.221.15.395.15h.157a3 3 0 0 1 .472.018c.08.01.193 0 .257.06.077.072.036.194.018.282-.05.246-.066.469-.066.72.328-.051.419-.576.535-.84.131-.298.265-.597.387-.9.06-.148.14-.314.119-.479-.024-.185-.157-.381-.25-.54-.177-.298-.378-.606-.508-.929-.104-.258-.007-.58.286-.672.161-.05.334.049.439.166.22.244.363.609.523.896l1.249 2.248q.159.286.32.57c.043.074.086.188.173.219.077.028.182-.012.26-.027.198-.04.398-.083.598-.12.24-.043.605-.035.778-.222-.253-.08-.545-.075-.808-.057-.158.01-.333.067-.479-.025-.216-.137-.36-.455-.492-.667-.326-.525-.633-1.057-.945-1.59l-.05-.084-.1-.17q-.075-.126-.149-.255c-.037-.066-.092-.153-.039-.227.056-.076.179-.08.29-.081h.021q.066.001.117-.004a10 10 0 0 1 1.347-.107c-.035-.122-.135-.26-.103-.39.071-.292.49-.383.686-.174.131.14.207.334.292.504.113.223.24.44.361.66.211.383.441.757.658 1.138l.055.094.028.047c.093.156.187.314.238.489-.753-.035-1.318-.909-1.646-1.499-.027.095.016.179.05.27q.103.282.262.54c.152.244.326.495.556.673.408.315.945.317 1.436.283.315-.022.708-.165 1.018-.068s.434.438.25.7c-.138.196-.321.27-.55.3.162.346.373.667.527 1.02.064.146.13.37.283.448.102.051.248.003.358 0-.11-.292-.317-.54-.419-.839.31.015.61.176.898.28.567.202 1.128.424 1.687.648l.258.104c.23.092.462.183.689.283.083.037.198.123.29.07.074-.043.123-.146.169-.215a10.3 10.3 0 0 0 1.393-3.208c.75-2.989.106-6.287-1.695-8.783-.692-.96-1.562-1.789-2.522-2.476-2.401-1.718-5.551-2.407-8.44-1.768m4.908 14.904c-.636.166-1.292.317-1.945.401.086.293.296.577.45.84.059.101.122.237.24.281.132.05.292-.03.417-.072-.058-.158-.155-.3-.235-.45-.033-.06-.084-.133-.056-.206.05-.137.263-.13.381-.153.31-.063.617-.142.928-.204.114-.023.274-.085.389-.047.086.03.138.1.187.174l.022.033q.043.07.097.122c.125.113.313.13.472.162-.097-.219-.259-.41-.362-.63-.06-.127-.11-.315-.242-.388-.182-.102-.557.089-.743.137m-4.01-1.457c-.03.38-.147.689-.33 1.019.21.026.423.036.629.087.154.038.296.11.449.153-.082-.224-.233-.423-.35-.63-.12-.208-.226-.462-.398-.63"/></svg>'
const REACT_ROUTER_LOGO =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.118 5.466a2.306 2.306 0 0 0-.623.08c-.278.067-.702.332-.953.583-.41.423-.49.609-.662 1.469-.08.423.41 1.43.847 1.734.45.317 1.085.502 2.065.608 1.429.16 1.84.636 1.84 2.197 0 1.377-.385 1.747-1.96 1.906-1.707.172-2.58.834-2.765 2.117-.106.781.41 1.76 1.125 2.091 1.627.768 3.15-.198 3.467-2.196.211-1.284.622-1.642 1.998-1.747 1.588-.133 2.409-.675 2.713-1.787.278-1.02-.304-2.157-1.297-2.554-.264-.106-.873-.238-1.35-.291-1.495-.16-1.879-.424-2.038-1.39-.225-1.337-.317-1.562-.794-2.09a2.174 2.174 0 0 0-1.613-.73zm-4.785 4.36a2.145 2.145 0 0 0-.497.048c-1.469.318-2.17 2.051-1.35 3.295 1.178 1.774 3.944.953 3.97-1.177.012-1.193-.98-2.143-2.123-2.166zM2.089 14.19a2.22 2.22 0 0 0-.427.052c-2.158.476-2.237 3.626-.106 4.182.53.145.582.145 1.111.013 1.191-.318 1.866-1.456 1.549-2.607-.278-1.02-1.144-1.664-2.127-1.64zm19.824.008c-.233.002-.477.058-.784.162-1.39.477-1.866 2.092-.98 3.336.557.794 1.96 1.058 2.82.516 1.416-.874 1.363-3.057-.093-3.746-.38-.186-.663-.271-.963-.268z"/></svg>'
const VITE_LOGO =
'<svg xmlns="http://www.w3.org/2000/svg" width="410" height="404" fill="none" viewBox="0 0 410 404"><path fill="var(--foreground)" d="m399.641 59.525-183.998 329.02c-3.799 6.793-13.559 6.833-17.415.073L10.582 59.556C6.38 52.19 12.68 43.266 21.028 44.76l184.195 32.923c1.175.21 2.378.208 3.553-.006l180.343-32.87c8.32-1.517 14.649 7.337 10.522 14.719"/><path fill="var(--background)" d="M292.965 1.574 156.801 28.255a5 5 0 0 0-4.03 4.611l-8.376 141.464c-.197 3.332 2.863 5.918 6.115 5.168l37.91-8.749c3.547-.818 6.752 2.306 6.023 5.873l-11.263 55.153c-.758 3.712 2.727 6.886 6.352 5.785l23.415-7.114c3.63-1.102 7.118 2.081 6.35 5.796l-17.899 86.633c-1.12 5.419 6.088 8.374 9.094 3.728l2.008-3.103 110.954-221.428c1.858-3.707-1.346-7.935-5.418-7.15l-39.022 7.532c-3.667.707-6.787-2.708-5.752-6.296l25.469-88.291c1.036-3.594-2.095-7.012-5.766-6.293"/></svg>'
const LARAVEL_LOGO =
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Laravel</title><path d="M23.642 5.43a.364.364 0 0 1 .014.1v5.149c0 .135-.073.26-.189.326l-4.323 2.49v4.934a.378.378 0 0 1-.188.326L9.93 23.949a.316.316 0 0 1-.066.027c-.008.002-.016.008-.024.01a.348.348 0 0 1-.192 0c-.011-.002-.02-.008-.03-.012-.02-.008-.042-.014-.062-.025L.533 18.755a.376.376 0 0 1-.189-.326V2.974c0-.033.005-.066.014-.098.003-.012.01-.02.014-.032a.369.369 0 0 1 .023-.058c.004-.013.015-.022.023-.033l.033-.045c.012-.01.025-.018.037-.027.014-.012.027-.024.041-.034h.001L5.044.05a.375.375 0 0 1 .375 0L9.933 2.697h.002c.015.01.027.021.04.033l.038.027c.013.014.02.03.033.045.008.011.02.021.025.033.01.02.017.038.024.058.003.011.01.021.013.032.01.031.014.064.014.098v9.652l3.76-2.164V5.527c0-.033.004-.066.013-.098.003-.01.01-.02.013-.032a.487.487 0 0 1 .024-.059c.007-.012.018-.02.025-.033.012-.015.021-.03.033-.043.012-.012.025-.02.037-.028.014-.011.026-.023.041-.032h.001l4.513-2.647a.375.375 0 0 1 .375 0l4.513 2.647c.016.011.029.021.042.031.012.01.025.018.036.028.013.014.022.03.034.045.008.012.019.021.024.033a.3.3 0 0 1 .024.06c.006.01.012.021.015.032zm-.74 5.032V5.860l-1.578.908-2.182 1.256v4.603zm-4.514 7.75v-4.605l-2.148 1.227-6.876 3.93v4.649zm-17.642-15.3v15.15l8.25 4.757v-4.645L4.699 15.87l-.003-.002-.002-.001c-.014-.01-.025-.021-.04-.033-.012-.01-.026-.018-.036-.028l-.001-.002c-.013-.012-.021-.028-.032-.043-.01-.013-.022-.023-.03-.037v-.002c-.01-.014-.016-.032-.023-.048-.006-.012-.016-.023-.02-.035l-.002-.001c-.005-.018-.008-.037-.011-.057L4.5 15.58v-9.51l-2.182-1.256-1.578-.908zm4.322-2.474L1.313 2.974l3.756 2.163 3.755-2.163zm2.068 11.22 2.182-1.256V1.974L7.937 3.23 5.755 4.486v11.186zm10.895-7.583-3.755 2.163 3.755 2.164 3.755-2.164zm-.375 4.976-2.182-1.256-1.578-.908v4.603l2.182 1.256 1.578.908zm-8.438 6.186 5.494-3.14 2.944-1.682-3.755-2.163-4.323 2.489-4.136 2.384z"/></svg>'
export const TEMPLATES = [
[
{ value: "next", title: "Next.js", logo: NEXT_LOGO },
{ value: "vite", title: "Vite", logo: VITE_LOGO },
],
[
{ value: "start", title: "TanStack Start", logo: START_LOGO },
{ value: "laravel", title: "Laravel", logo: LARAVEL_LOGO },
{ value: "react-router", title: "React Router", logo: REACT_ROUTER_LOGO },
],
] as const
export const ALL_TEMPLATES = TEMPLATES.flat()
// Extract the base framework from a template value (e.g. "next-monorepo" -> "next").
export function getFramework(template: string) {
return template.replace(
"-monorepo",
""
) as (typeof ALL_TEMPLATES)[number]["value"]
}
// Frameworks that don't support the monorepo template.
export const NO_MONOREPO_FRAMEWORKS = ["laravel"] as const
// Build the full template value from a framework and monorepo flag.
export function getTemplateValue(framework: string, monorepo: boolean) {
if (
NO_MONOREPO_FRAMEWORKS.includes(
framework as (typeof NO_MONOREPO_FRAMEWORKS)[number]
)
) {
return framework
}
return monorepo ? `${framework}-monorepo` : framework
}
export function TemplatePicker({
isMobile,
anchorRef,
@@ -37,8 +71,11 @@ export function TemplatePicker({
}) {
const [params, setParams] = useDesignSystemSearchParams()
const currentTemplate = TEMPLATES.find(
(template) => template.value === params.template
const isMonorepo = params.template?.endsWith("-monorepo") ?? false
const framework = getFramework(params.template ?? "next")
const currentTemplate = ALL_TEMPLATES.find(
(template) => template.value === framework
)
return (
@@ -48,6 +85,7 @@ export function TemplatePicker({
<div className="text-muted-foreground text-xs">Template</div>
<div className="text-foreground text-sm font-medium">
{currentTemplate?.title}
{isMonorepo ? " (Monorepo)" : ""}
</div>
</div>
{currentTemplate?.logo && (
@@ -65,15 +103,21 @@ export function TemplatePicker({
align={isMobile ? "center" : "start"}
>
<PickerRadioGroup
value={params.template}
value={framework}
onValueChange={(value) => {
const canMonorepo = !NO_MONOREPO_FRAMEWORKS.includes(
value as (typeof NO_MONOREPO_FRAMEWORKS)[number]
)
setParams({
template: value as "next" | "next-monorepo" | "start" | "vite",
template: getTemplateValue(
value,
canMonorepo && isMonorepo
) as typeof params.template,
})
}}
>
<PickerGroup>
{TEMPLATES.map((template) => (
{ALL_TEMPLATES.map((template) => (
<PickerRadioItem key={template.value} value={template.value}>
{template.logo && (
<div

View File

@@ -1,16 +1,18 @@
"use client"
import * as React from "react"
import { Button } from "@/examples/base/ui/button"
import { Skeleton } from "@/examples/base/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/examples/base/ui/tooltip"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { useMounted } from "@/hooks/use-mounted"
import { Icons } from "@/components/icons"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Skeleton } from "@/registry/new-york-v4/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
export function V0Button({ className }: { className?: string }) {
@@ -18,7 +20,23 @@ export function V0Button({ className }: { className?: string }) {
const isMobile = useIsMobile()
const isMounted = useMounted()
const url = `${process.env.NEXT_PUBLIC_APP_URL}/create/v0?base=${params.base}&style=${params.style}&baseColor=${params.baseColor}&theme=${params.theme}&iconLibrary=${params.iconLibrary}&font=${params.font}&menuAccent=${params.menuAccent}&menuColor=${params.menuColor}&radius=${params.radius}&item=${params.item}`
// Memoize to avoid string concatenation on every render. (rerender-derived-state)
const url = React.useMemo(
() =>
`${process.env.NEXT_PUBLIC_APP_URL}/create/v0?base=${params.base}&style=${params.style}&baseColor=${params.baseColor}&theme=${params.theme}&iconLibrary=${params.iconLibrary}&font=${params.font}&menuAccent=${params.menuAccent}&menuColor=${params.menuColor}&radius=${params.radius}&item=${params.item}`,
[
params.base,
params.style,
params.baseColor,
params.theme,
params.iconLibrary,
params.font,
params.menuAccent,
params.menuColor,
params.radius,
params.item,
]
)
if (!isMounted) {
return <Skeleton className="h-8 w-24 rounded-lg" />
@@ -26,21 +44,22 @@ export function V0Button({ className }: { className?: string }) {
return (
<Button
size="sm"
nativeButton={false}
role="link"
variant={isMobile ? "default" : "outline"}
className={cn(
"w-24 gap-1 rounded-lg shadow-none data-[variant=default]:h-[31px] lg:w-8 xl:w-24",
"w-24 gap-1 data-[variant=default]:h-[31px] lg:w-8 xl:w-24",
className
)}
asChild
render={
<a
href={`${process.env.NEXT_PUBLIC_V0_URL}/chat/api/open?url=${encodeURIComponent(url)}&title=${params.item}`}
target="_blank"
/>
}
>
<a
href={`${process.env.NEXT_PUBLIC_V0_URL}/chat/api/open?url=${encodeURIComponent(url)}&title=${params.item}`}
target="_blank"
>
<span className="lg:hidden xl:block">Open in</span>
<Icons.v0 className="size-5" />
</a>
<span className="lg:hidden xl:block">Open in</span>
<Icons.v0 className="size-5" data-icon="inline-end" />
</Button>
)
}

View File

@@ -1,9 +1,7 @@
"use client"
import * as React from "react"
import { Icons } from "@/components/icons"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Button } from "@/examples/base/ui/button"
import {
Dialog,
DialogClose,
@@ -12,7 +10,9 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/registry/new-york-v4/ui/dialog"
} from "@/examples/base/ui/dialog"
import { Icons } from "@/components/icons"
const STORAGE_KEY = "shadcn-create-welcome-dialog"
@@ -26,12 +26,13 @@ export function WelcomeDialog() {
}
}, [])
const handleOpenChange = (open: boolean) => {
// 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}>
@@ -57,10 +58,8 @@ export function WelcomeDialog() {
</DialogDescription>
</DialogHeader>
<DialogFooter className="p-4 pt-0">
<DialogClose asChild>
<Button className="w-full rounded-lg shadow-none">
Get Started
</Button>
<DialogClose render={<Button className="w-full" />}>
Get Started
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -1,9 +1,9 @@
import { type Metadata } from "next"
import { SidebarProvider } from "@/examples/base/ui/sidebar"
import { siteConfig } from "@/lib/config"
import { absoluteUrl } from "@/lib/utils"
import { BASES, type BaseName } from "@/registry/config"
import { SidebarProvider } from "@/registry/new-york-v4/ui/sidebar"
import { Customizer } from "@/app/(create)/components/customizer"
import { PageHeader } from "@/app/(create)/components/page-header"
import { PresetHandler } from "@/app/(create)/components/preset-handler"
@@ -11,9 +11,6 @@ import { Preview } from "@/app/(create)/components/preview"
import { WelcomeDialog } from "@/app/(create)/components/welcome-dialog"
import { getItemsForBase } from "@/app/(create)/lib/api"
export const revalidate = false
export const dynamic = "force-static"
export const metadata: Metadata = {
title: "New Project",
description:
@@ -47,21 +44,24 @@ async function getAllItems() {
const entries = await Promise.all(
BASES.map(async (base) => {
const items = await getItemsForBase(base.name as BaseName)
const filtered = items
.filter((item) => item !== null)
.map((item) => ({
name: item.name,
title: item.title,
type: item.type,
}))
.filter((item) => !/\d+$/.test(item.name))
// Single pass: filter nulls, strip to {name, title, type}, skip numeric suffixes. (js-combine-iterations)
const filtered: Pick<
NonNullable<(typeof items)[number]>,
"name" | "title" | "type"
>[] = []
for (const item of items) {
if (item !== null && !/\d+$/.test(item.name)) {
filtered.push({
name: item.name,
title: item.title,
type: item.type,
})
}
}
return [base.name, filtered] as const
})
)
return Object.fromEntries(entries) as Record<
string,
{ name: string; title: string | undefined; type: string }[]
>
return Object.fromEntries(entries)
}
export default async function CreatePage() {

View File

@@ -1,4 +1,4 @@
import { NextResponse, type NextRequest } from "next/server"
import { after, NextResponse, type NextRequest } from "next/server"
import { track } from "@vercel/analytics/server"
import dedent from "dedent"
import {
@@ -46,7 +46,10 @@ export async function GET(request: NextRequest) {
const designSystemConfig = parseResult.data
track("create_open_in_v0", designSystemConfig)
// Defer analytics to after response is sent. (server-after-nonblocking)
after(() => {
track("create_open_in_v0", designSystemConfig)
})
const payload = await buildV0Payload(designSystemConfig)
@@ -218,11 +221,13 @@ async function buildComponentFiles(designSystemConfig: DesignSystemConfig) {
)
.map((item) => item.name)
// Fetch UI components and the item component in parallel. (async-parallel)
const itemComponentPromise = designSystemConfig.item
? getRegistryItemFile(designSystemConfig.item, designSystemConfig)
: null
const registryItemFiles = await Promise.all(
allItemsForBase.map(async (name) => {
const file = await getRegistryItemFile(name, designSystemConfig)
return file
})
allItemsForBase.map((name) => getRegistryItemFile(name, designSystemConfig))
)
files.push(...registryItemFiles)
@@ -239,11 +244,8 @@ async function buildComponentFiles(designSystemConfig: DesignSystemConfig) {
}
// Build the actual item component.
if (designSystemConfig.item) {
const itemComponentFile = await getRegistryItemFile(
designSystemConfig.item,
designSystemConfig
)
if (itemComponentPromise) {
const itemComponentFile = await itemComponentPromise
if (itemComponentFile) {
// Find the export default function from the component file.
const exportDefault = itemComponentFile.content.match(
@@ -349,16 +351,16 @@ async function getRegistryItemFile(
const transformers = [transformIcons, transformMenu, transformRender]
// Reuse a single ts-morph Project — avoids re-creating the compiler host per file. (js-cache-function-results)
const project = new Project({ compilerOptions: {} })
async function transformFileContent(
content: string,
config: z.infer<typeof configSchema>
) {
const project = new Project({
compilerOptions: {},
})
const sourceFile = project.createSourceFile("component.tsx", content, {
scriptKind: ScriptKind.TSX,
overwrite: true,
})
for (const transformer of transformers) {

View File

@@ -16,67 +16,117 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [currentIndex, setCurrentIndex] = React.useState(0)
const preset = searchParams.get("preset") ?? ""
const entriesRef = React.useRef<string[]>([preset])
const indexRef = React.useRef(0)
const maxIndexRef = React.useRef(0)
const isNavigatingRef = React.useRef(false)
const [index, setIndex] = React.useState(0)
const [maxIndex, setMaxIndex] = React.useState(0)
const entriesRef = React.useRef<string[]>([])
const pendingIndexRef = React.useRef<number | null>(null)
React.useEffect(() => {
const query = searchParams.toString()
const entries = entriesRef.current
if (entries.length === 0) {
entriesRef.current = [query]
setCurrentIndex(0)
setMaxIndex(0)
pendingIndexRef.current = null
if (isNavigatingRef.current) {
isNavigatingRef.current = false
return
}
const pendingIndex = pendingIndexRef.current
if (pendingIndex !== null) {
pendingIndexRef.current = null
if (entries[pendingIndex] === query) {
setCurrentIndex(pendingIndex)
setMaxIndex(entries.length - 1)
return
}
}
const currentQuery = entries[currentIndex]
if (query === currentQuery) {
if (preset === entriesRef.current[indexRef.current]) {
return
}
const nextEntries = entries.slice(0, currentIndex + 1)
nextEntries.push(query)
const nextEntries = entriesRef.current.slice(0, indexRef.current + 1)
nextEntries.push(preset)
entriesRef.current = nextEntries
const nextIndex = nextEntries.length - 1
setCurrentIndex(nextIndex)
indexRef.current = nextIndex
maxIndexRef.current = nextIndex
setIndex(nextIndex)
setMaxIndex(nextIndex)
}, [searchParams, currentIndex])
}, [preset])
const canGoBack = currentIndex > 0
const canGoForward = currentIndex < maxIndex
const canGoBack = index > 0
const canGoForward = index < maxIndex
const goBack = React.useCallback(() => {
if (currentIndex > 0) {
const nextIndex = currentIndex - 1
const query = entriesRef.current[nextIndex]
pendingIndexRef.current = nextIndex
router.replace(query ? `${pathname}?${query}` : pathname)
if (indexRef.current <= 0) {
return
}
}, [currentIndex, pathname, router])
isNavigatingRef.current = true
const nextIndex = indexRef.current - 1
indexRef.current = nextIndex
setIndex(nextIndex)
const targetPreset = entriesRef.current[nextIndex]
const params = new URLSearchParams(window.location.search)
if (targetPreset) {
params.set("preset", targetPreset)
} else {
params.delete("preset")
}
const query = params.toString()
router.replace(query ? `${pathname}?${query}` : pathname)
}, [pathname, router])
const goForward = React.useCallback(() => {
if (currentIndex < maxIndex) {
const nextIndex = currentIndex + 1
const query = entriesRef.current[nextIndex]
pendingIndexRef.current = nextIndex
router.replace(query ? `${pathname}?${query}` : pathname)
if (indexRef.current >= maxIndexRef.current) {
return
}
}, [currentIndex, maxIndex, pathname, router])
isNavigatingRef.current = true
const nextIndex = indexRef.current + 1
indexRef.current = nextIndex
setIndex(nextIndex)
const targetPreset = entriesRef.current[nextIndex]
const params = new URLSearchParams(window.location.search)
if (targetPreset) {
params.set("preset", targetPreset)
} else {
params.delete("preset")
}
const query = params.toString()
router.replace(query ? `${pathname}?${query}` : pathname)
}, [pathname, router])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
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
}
const key = e.key.toLowerCase()
if ((key === "z" && e.shiftKey) || (key === "y" && e.ctrlKey)) {
e.preventDefault()
goForward()
return
}
if (key === "z") {
e.preventDefault()
goBack()
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [goBack, goForward])
const value = React.useMemo(
() => ({ canGoBack, canGoForward, goBack, goForward }),

View File

@@ -0,0 +1,113 @@
"use client"
import * as React from "react"
import {
BASE_COLORS,
getThemesForBaseColor,
iconLibraries,
MENU_ACCENTS,
MENU_COLORS,
RADII,
STYLES,
} from "@/registry/config"
import { useLocks } from "@/app/(create)/hooks/use-locks"
import { FONTS } from "@/app/(create)/lib/fonts"
import {
applyBias,
RANDOMIZE_BIASES,
type RandomizeContext,
} from "@/app/(create)/lib/randomize-biases"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
function randomItem<T>(array: readonly T[]): T {
return array[Math.floor(Math.random() * array.length)]
}
export function useRandom() {
const { locks } = useLocks()
const [params, setParams] = useDesignSystemSearchParams()
const randomize = React.useCallback(() => {
const selectedStyle = locks.has("style")
? params.style
: randomItem(STYLES).name
const context: RandomizeContext = {
style: selectedStyle,
}
const availableBaseColors = applyBias(
BASE_COLORS,
context,
RANDOMIZE_BIASES.baseColors
)
const baseColor = locks.has("baseColor")
? params.baseColor
: randomItem(availableBaseColors).name
context.baseColor = baseColor
const availableThemes = getThemesForBaseColor(baseColor)
const availableFonts = applyBias(FONTS, context, RANDOMIZE_BIASES.fonts)
const availableRadii = applyBias(RADII, context, RANDOMIZE_BIASES.radius)
const selectedTheme = locks.has("theme")
? params.theme
: randomItem(availableThemes).name
const selectedFont = locks.has("font")
? params.font
: randomItem(availableFonts).value
const selectedRadius = locks.has("radius")
? params.radius
: randomItem(availableRadii).name
const selectedIconLibrary = locks.has("iconLibrary")
? params.iconLibrary
: randomItem(Object.values(iconLibraries)).name
const selectedMenuAccent = locks.has("menuAccent")
? params.menuAccent
: randomItem(MENU_ACCENTS).value
const selectedMenuColor = locks.has("menuColor")
? params.menuColor
: randomItem(MENU_COLORS).value
context.theme = selectedTheme
context.font = selectedFont
context.radius = selectedRadius
setParams({
style: selectedStyle,
baseColor,
theme: selectedTheme,
iconLibrary: selectedIconLibrary,
font: selectedFont,
menuAccent: selectedMenuAccent,
menuColor: selectedMenuColor,
radius: selectedRadius,
})
}, [setParams, locks, params])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
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()
randomize()
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [randomize])
return { randomize }
}

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import useSWR from "swr"
import { DEFAULT_CONFIG } from "@/registry/config"
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
const RESET_DIALOG_KEY = "create:reset-dialog-open"
export function useReset() {
const [params, setParams] = useDesignSystemSearchParams()
const { data: showResetDialog = false, mutate: setShowResetDialogData } =
useSWR<boolean>(RESET_DIALOG_KEY, {
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
})
const reset = React.useCallback(() => {
setParams({
base: params.base,
style: DEFAULT_CONFIG.style,
baseColor: DEFAULT_CONFIG.baseColor,
theme: DEFAULT_CONFIG.theme,
iconLibrary: DEFAULT_CONFIG.iconLibrary,
font: DEFAULT_CONFIG.font,
menuAccent: DEFAULT_CONFIG.menuAccent,
menuColor: DEFAULT_CONFIG.menuColor,
radius: DEFAULT_CONFIG.radius,
template: DEFAULT_CONFIG.template,
item: "preview",
})
}, [setParams, params.base])
const handleShowResetDialogChange = React.useCallback(
(open: boolean) => {
void setShowResetDialogData(open, { revalidate: false })
},
[setShowResetDialogData]
)
const confirmReset = React.useCallback(() => {
reset()
void setShowResetDialogData(false, { revalidate: false })
}, [reset, setShowResetDialogData])
return {
reset,
showResetDialog,
setShowResetDialog: handleShowResetDialogChange,
confirmReset,
}
}

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import { useTheme } from "next-themes"
import { useMetaColor } from "@/hooks/use-meta-color"
export function useThemeToggle() {
const { setTheme, resolvedTheme } = useTheme()
const { setMetaColor, metaColor } = useMetaColor()
React.useEffect(() => {
setMetaColor(metaColor)
}, [metaColor, setMetaColor])
const toggleTheme = React.useCallback(() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}, [resolvedTheme, setTheme])
// Listen for the D key to toggle theme.
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
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()
toggleTheme()
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [toggleTheme])
return { toggleTheme }
}

View File

@@ -68,9 +68,12 @@ const designSystemSearchParams = {
"next",
"next-monorepo",
"start",
"start-monorepo",
"react-router",
"react-router-monorepo",
"vite",
"existing",
"vite-monorepo",
"laravel",
] as const).withDefault("next"),
rtl: parseAsBoolean.withDefault(false),
size: parseAsInteger.withDefault(100),

View File

@@ -4,11 +4,12 @@ import { notFound } from "next/navigation"
import { siteConfig } from "@/lib/config"
import { absoluteUrl } from "@/lib/utils"
import { DarkModeScript } from "@/components/mode-switcher"
import { TailwindIndicator } from "@/components/tailwind-indicator"
import { BASES, type Base } from "@/registry/config"
import { ActionMenuScript } from "@/app/(create)/components/action-menu"
import { DesignSystemProvider } from "@/app/(create)/components/design-system-provider"
import { ItemPickerScript } from "@/app/(create)/components/item-picker"
import { HistoryScript } from "@/app/(create)/components/history-buttons"
import { DarkModeScript } from "@/app/(create)/components/mode-switcher"
import { PreviewStyle } from "@/app/(create)/components/preview-style"
import { RandomizeScript } from "@/app/(create)/components/random-button"
import { getBaseComponent, getBaseItem } from "@/app/(create)/lib/api"
@@ -142,8 +143,9 @@ export default async function BlockPage({
<div className="relative">
<PreventScrollOnFocusScript />
<PreviewStyle />
<ItemPickerScript />
<ActionMenuScript />
<RandomizeScript />
<HistoryScript />
<DarkModeScript />
<DesignSystemProvider>
<Component />