mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-03 01:18:38 +00:00
refactor: rework create page
This commit is contained in:
390
apps/v4/app/(create)/components/action-menu.tsx
Normal file
390
apps/v4/app/(create)/components/action-menu.tsx
Normal 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
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
78
apps/v4/app/(create)/components/history-buttons.tsx
Normal file
78
apps/v4/app/(create)/components/history-buttons.tsx
Normal 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}' }, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
85
apps/v4/app/(create)/components/main-menu.tsx
Normal file
85
apps/v4/app/(create)/components/main-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
apps/v4/app/(create)/components/mode-switcher.tsx
Normal file
102
apps/v4/app/(create)/components/mode-switcher.tsx
Normal 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
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
113
apps/v4/app/(create)/hooks/use-random.tsx
Normal file
113
apps/v4/app/(create)/hooks/use-random.tsx
Normal 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 }
|
||||
}
|
||||
55
apps/v4/app/(create)/hooks/use-reset.tsx
Normal file
55
apps/v4/app/(create)/hooks/use-reset.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
48
apps/v4/app/(create)/hooks/use-theme-toggle.tsx
Normal file
48
apps/v4/app/(create)/hooks/use-theme-toggle.tsx
Normal 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 }
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user