Compare commits

..

1 Commits

Author SHA1 Message Date
shadcn
3a5fa409eb fix 2025-10-15 11:10:00 +04:00
9843 changed files with 316339 additions and 516319 deletions

View File

@@ -7,5 +7,5 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["v4", "tests"]
"ignore": ["www", "v4", "tests"]
}

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
Fix support for universal registry items that only have dependencies without files

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
add support for color as var

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
fix adding registry item with CSS at-property

View File

@@ -2,12 +2,8 @@
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(npm run typecheck:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"WebSearch",
"WebFetch(domain:github.com)"
"Bash(npm run typecheck:*)"
],
"deny": []
}
}
}

View File

@@ -1,22 +0,0 @@
---
description: Keep registry base and radix trees in sync when editing shared UI
globs: apps/v4/registry/bases/**/*
alwaysApply: false
---
# Registry bases: Base UI ↔ Radix parity
`apps/v4/registry/bases/base` and `apps/v4/registry/bases/radix` are **parallel registries**. Anything that exists in both trees for the same purpose (preview blocks, mirrored examples, shared card layouts, etc.) **must stay in sync**.
## When editing
- If you change a file under **`bases/base/...`**, apply the **same behavioral and visual change** to the matching path under **`bases/radix/...`** (and the reverse).
- Only diverge where APIs differ (e.g. import paths like `@/registry/bases/base/ui/*` vs `@/registry/bases/radix/ui/*`, or Base UI vs Radix component props).
- Do **not** update only one side unless the user explicitly asks for a single-base change.
## Typical mirrored paths
- `blocks/preview/**` — preview cards and blocks
- Parallel `ui/*` components when both exist for the same component
After edits, briefly confirm both trees were updated (or state why one side is intentionally unchanged).

View File

@@ -1,12 +1,12 @@
// ORIGINALLY FROM CLOUDFLARE WRANGLER:
// https://github.com/cloudflare/wrangler2/blob/main/.github/changeset-version.js
import { execSync } from "child_process"
import { exec } from "child_process"
// This script is used by the `release.yml` workflow to update the version of the packages being released.
// The standard step is only to run `changeset version` but this does not update the pnpm-lock.yaml file.
// So we also run `pnpm install`, which does this update.
// The standard step is only to run `changeset version` but this does not update the package-lock.json file.
// So we also run `npm install`, which does this update.
// This is a workaround until this is handled automatically by `changeset version`.
// See https://github.com/changesets/changesets/issues/421.
execSync("npx changeset version", { stdio: "inherit" })
execSync("pnpm install --lockfile-only", { stdio: "inherit" })
exec("npx changeset version")
exec("npm install")

View File

@@ -4,43 +4,3 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/astro-app"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/astro-monorepo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/next-app"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/next-monorepo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/react-router-app"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/react-router-monorepo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/start-app"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/start-monorepo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/vite-app"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/templates/vite-monorepo"
schedule:
interval: "weekly"

View File

@@ -77,9 +77,6 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Build packages
run: pnpm --filter=shadcn build
- run: pnpm format:check
tsc:

75
.github/workflows/deprecated.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Deprecated
on:
pull_request:
types: [opened, synchronize]
permissions:
issues: write
contents: read
pull-requests: write
jobs:
deprecated:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
with:
files: |
apps/www/**
files_ignore: |
apps/www/public/r/**
base_sha: ${{ github.event.pull_request.base.sha }}
sha: ${{ github.event.pull_request.head.sha }}
- name: Comment on PR if www files changed
if: steps.changed-files.outputs.any_changed == 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const changedFiles = `${{ steps.changed-files.outputs.all_changed_files }}`.split(' ');
const wwwFiles = changedFiles.filter(file =>
file.startsWith('apps/www/') &&
!file.startsWith('apps/www/public/r/')
);
if (wwwFiles.length > 0) {
const comment = `Looks like this PR modifies files in \`apps/www\`, which is deprecated.
Consider applying the change to \`apps/v4\` if relevant.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
// Add deprecated label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['deprecated']
});
} else {
// Remove deprecated label if no www files are changed
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: 'deprecated'
});
} catch (error) {
// Label doesn't exist, which is fine
console.log('Deprecated label not found, skipping removal');
}
}

View File

@@ -7,11 +7,6 @@ on:
types: [labeled]
branches:
- main
permissions:
id-token: write
contents: read
jobs:
prerelease:
if: |
@@ -23,7 +18,7 @@ jobs:
steps:
- name: Checkout Repo
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 0
@@ -32,22 +27,23 @@ jobs:
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
- name: Use Node.js 18
uses: actions/setup-node@v3
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
node-version: 18
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Authenticate to NPM
run: echo "//registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN" >> packages/shadcn/.npmrc
env:
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Publish Beta to NPM
run: pnpm pub:beta

View File

@@ -7,11 +7,6 @@ on:
branches:
- main
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
release:
if: ${{ github.repository_owner == 'shadcn-ui' }}
@@ -28,16 +23,13 @@ jobs:
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
- name: Use Node.js 18
uses: actions/setup-node@v3
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
version: 9.0.6
node-version: 18
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
@@ -57,4 +49,5 @@ jobs:
publish: npx changeset publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
NODE_ENV: "production"

View File

@@ -19,7 +19,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 22
node-version: 18
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -42,4 +42,7 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Build packages
run: pnpm build --filter=shadcn
- run: pnpm test

View File

@@ -4,53 +4,13 @@ on:
pull_request:
paths:
- "apps/v4/public/r/registries.json"
- "apps/v4/registry/directory.json"
push:
branches:
- main
paths:
- "apps/v4/public/r/registries.json"
- "apps/v4/registry/directory.json"
jobs:
check-registry-sync:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
name: check-registry-sync
permissions:
contents: read
pull-requests: write
steps:
- name: Check changed files
id: changed
env:
GH_TOKEN: ${{ github.token }}
run: |
CHANGED_FILES=$(gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --name-only)
DIRECTORY_CHANGED=false
REGISTRIES_CHANGED=false
if echo "$CHANGED_FILES" | grep -q "^apps/v4/registry/directory.json$"; then
DIRECTORY_CHANGED=true
fi
if echo "$CHANGED_FILES" | grep -q "^apps/v4/public/r/registries.json$"; then
REGISTRIES_CHANGED=true
fi
echo "directory_changed=$DIRECTORY_CHANGED" >> $GITHUB_OUTPUT
echo "registries_changed=$REGISTRIES_CHANGED" >> $GITHUB_OUTPUT
- name: Flag missing registries.json update
if: steps.changed.outputs.directory_changed == 'true' && steps.changed.outputs.registries_changed == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr edit ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --add-label "registries: invalid"
gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body "can you run \`pnpm registry:build\` and commit the json files please?"
exit 1
validate:
runs-on: ubuntu-latest
name: pnpm validate:registries
@@ -66,44 +26,6 @@ jobs:
with:
node-version: 20
- name: Block reserved registry namespaces
env:
RESERVED_NAMESPACES: "@shadcn,@ui,@blocks,@components,@block,@component,@util,@utils,@registry,@lib,@hook,@hooks,@theme,@themes,@chart,@charts"
run: |
node <<'EOF'
const fs = require("node:fs")
const files = [
"apps/v4/public/r/registries.json",
"apps/v4/registry/directory.json",
]
const reservedNamespaces = new Set(
process.env.RESERVED_NAMESPACES.split(",").filter(Boolean)
)
function readNames(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8")).map(
(entry) => entry.name
)
}
const violations = files.flatMap((filePath) => {
return readNames(filePath)
.filter((name) => reservedNamespaces.has(name))
.map((name) => `${filePath}: ${name}`)
})
if (violations.length > 0) {
console.error("Reserved registry namespaces are not allowed:")
for (const violation of violations) {
console.error(`- ${violation}`)
}
process.exit(1)
}
EOF
- uses: pnpm/action-setup@v4
name: Install pnpm
id: pnpm-install
@@ -125,5 +47,8 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Build packages
run: pnpm build --filter=shadcn
- name: Validate registries
run: pnpm --filter=v4 validate:registries

6
.gitignore vendored
View File

@@ -15,7 +15,6 @@ build
# misc
.DS_Store
.eslintcache
*.pem
# debug
@@ -40,8 +39,3 @@ tsconfig.tsbuildinfo
.idea
.fleet
.vscode
.notes
.playwright-mcp
shadcn-workspace
.codex-artifacts

View File

@@ -3,6 +3,5 @@ node_modules
.next
build
.contentlayer
apps/www/pages/api/registry.json
**/fixtures
deprecated
apps/v4/registry/styles/**/*.css

View File

@@ -10,11 +10,6 @@
"**/fixtures/**"
],
"files.exclude": {
"deprecated": true
},
"search.exclude": {
"apps/v4/registry/radix-*": true,
"apps/v4/public/r/*": true,
"packages/shadcn/test/fixtures/*": true
"apps/www": true
}
}

View File

@@ -20,25 +20,28 @@ This repository is structured as follows:
```
apps
└── v4
└── www
├── app
├── components
├── content
└── registry
── new-york-v4
── default
│ ├── example
│ └── ui
└── new-york
├── example
└── ui
packages
└── shadcn
└── cli
```
| Path | Description |
| -------------------- | ---------------------------------------- |
| `apps/v4/app` | The Next.js application for the website. |
| `apps/v4/components` | The React components for the website. |
| `apps/v4/content` | The content for the website. |
| `apps/v4/registry` | The registry for the components. |
| `packages/shadcn` | The `shadcn` package. |
| Path | Description |
| --------------------- | ---------------------------------------- |
| `apps/www/app` | The Next.js application for the website. |
| `apps/www/components` | The React components for the website. |
| `apps/www/content` | The content for the website. |
| `apps/www/registry` | The registry for the components. |
| `packages/cli` | The `shadcn-ui` package. |
## Development
@@ -79,26 +82,32 @@ You can use the `pnpm --filter=[WORKSPACE]` command to start the development pro
1. To run the `ui.shadcn.com` website:
```bash
pnpm --filter=v4 dev
pnpm --filter=www dev
```
2. To run the `shadcn` package:
2. To run the `shadcn-ui` package:
```bash
pnpm --filter=shadcn dev
pnpm --filter=shadcn-ui dev
```
## Running the CLI Locally
To run the CLI locally, you can follow the workflow:
1. Start by running the dev server:
1. Start by running the registry (main site) to make sure the components are up to date:
```bash
pnpm dev
pnpm v4:dev
```
2. In another terminal tab, test the CLI by running:
2. Run the development script for the CLI:
```bash
pnpm shadcn:dev
```
3. In another terminal tab, test the CLI by running:
```bash
pnpm shadcn
@@ -110,27 +119,36 @@ To run the CLI locally, you can follow the workflow:
pnpm shadcn <init | add | ...> -c ~/Desktop/my-app
```
4. To run the tests for the CLI:
```bash
pnpm --filter=shadcn test
```
This workflow ensures that you are running the most recent version of the registry and testing the CLI properly in your local environment.
## Documentation
The documentation for this project is located in the `v4` workspace. You can run the documentation locally by running the following command:
The documentation for this project is located in the `www` workspace. You can run the documentation locally by running the following command:
```bash
pnpm --filter=v4 dev
pnpm --filter=www dev
```
Documentation is written using [MDX](https://mdxjs.com). You can find the documentation files in the `apps/v4/content/docs` directory.
Documentation is written using [MDX](https://mdxjs.com). You can find the documentation files in the `apps/www/content/docs` directory.
## Components
We use a registry system for developing components. You can find the source code for the components under `apps/v4/registry`. The components are organized by styles.
We use a registry system for developing components. You can find the source code for the components under `apps/www/registry`. The components are organized by styles.
```bash
apps
└── v4
└── www
└── registry
── new-york-v4
── default
│ ├── example
│ └── ui
└── new-york
├── example
└── ui
```
@@ -139,7 +157,7 @@ When adding or modifying components, please ensure that:
1. You make the changes for every style.
2. You update the documentation.
3. You run `pnpm registry:build` to update the registry.
3. You run `pnpm build:registry` to update the registry.
## Commit Convention
@@ -178,9 +196,9 @@ If you have a request for a new component, please open a discussion on GitHub. W
## CLI
The `shadcn` package is a CLI for adding components to your project. You can find the documentation for the CLI [here](https://ui.shadcn.com/docs/cli).
The `shadcn-ui` package is a CLI for adding components to your project. You can find the documentation for the CLI [here](https://ui.shadcn.com/docs/cli).
Any changes to the CLI should be made in the `packages/shadcn` directory. If you can, it would be great if you could add tests for your changes.
Any changes to the CLI should be made in the `packages/cli` directory. If you can, it would be great if you could add tests for your changes.
## Testing

View File

@@ -1,12 +1,12 @@
# shadcn/ui
A set of beautifully designed components that you can customize, extend, and build on. Start here then make it your own. Open Source. Open Code. **Use this to build your own component library**.
Accessible and customizable components that you can copy and paste into your apps. Free. Open Source. **Use this to build your own component library**.
![hero](apps/v4/public/opengraph-image.png)
![hero](apps/www/public/og.jpg)
## Documentation
Visit https://ui.shadcn.com/docs to view the documentation.
Visit http://ui.shadcn.com/docs to view the documentation.
## Contributing
@@ -14,4 +14,4 @@ Please read the [contributing guide](/CONTRIBUTING.md).
## License
Licensed under the [MIT license](./LICENSE.md).
Licensed under the [MIT license](https://github.com/shadcn/ui/blob/main/LICENSE.md).

View File

@@ -6,4 +6,4 @@ We will investigate all legitimate reports and do our best to quickly fix the pr
Our preference is that you make use of GitHub's private vulnerability reporting feature to disclose potential security vulnerabilities in our Open Source Software.
To do this, please visit the security tab of the repository and click the [Report a vulnerability](https://github.com/shadcn-ui/ui/security/advisories/new) button.
To do this, please visit the security tab of the repository and click the "Report a vulnerability" button.

View File

@@ -5,4 +5,3 @@ build
.contentlayer
registry/__index__.tsx
content/docs/components/calendar.mdx
registry/styles/**/*.css

View File

@@ -1,10 +1,11 @@
"use client"
import * as React from "react"
import { IconMinus, IconPlus } from "@tabler/icons-react"
import { CheckIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { useThemeConfig } from "@/components/active-theme"
import { Button } from "@/registry/new-york-v4/ui/button"
import { ButtonGroup } from "@/registry/new-york-v4/ui/button-group"
import {
Field,
FieldContent,
@@ -15,30 +16,36 @@ import {
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/styles/radix-nova/ui/field"
import { Input } from "@/styles/radix-nova/ui/input"
import { RadioGroup, RadioGroupItem } from "@/styles/radix-nova/ui/radio-group"
import { Switch } from "@/styles/radix-nova/ui/switch"
} from "@/registry/new-york-v4/ui/field"
import { Input } from "@/registry/new-york-v4/ui/input"
import { Label } from "@/registry/new-york-v4/ui/label"
import {
RadioGroup,
RadioGroupItem,
} from "@/registry/new-york-v4/ui/radio-group"
import { Switch } from "@/registry/new-york-v4/ui/switch"
const accents = [
{
name: "Blue",
value: "blue",
},
{
name: "Amber",
value: "amber",
},
{
name: "Green",
value: "green",
},
{
name: "Rose",
value: "rose",
},
]
export function AppearanceSettings() {
const [gpuCount, setGpuCount] = React.useState(8)
const handleGpuAdjustment = React.useCallback((adjustment: number) => {
setGpuCount((prevCount) =>
Math.max(1, Math.min(99, prevCount + adjustment))
)
}, [])
const handleGpuInputChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10)
if (!isNaN(value) && value >= 1 && value <= 99) {
setGpuCount(value)
}
},
[]
)
const { activeTheme, setActiveTheme } = useThemeConfig()
return (
<FieldSet>
<FieldGroup>
@@ -83,6 +90,37 @@ export function AppearanceSettings() {
</RadioGroup>
</FieldSet>
<FieldSeparator />
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>Accent</FieldTitle>
<FieldDescription>Select the accent color.</FieldDescription>
</FieldContent>
<FieldSet aria-label="Accent">
<RadioGroup
className="flex flex-wrap gap-2"
value={activeTheme}
onValueChange={setActiveTheme}
>
{accents.map((accent) => (
<Label
htmlFor={accent.value}
key={accent.value}
data-theme={accent.value}
className="flex size-6 items-center justify-center rounded-full data-[theme=amber]:bg-amber-600 data-[theme=blue]:bg-blue-700 data-[theme=green]:bg-green-600 data-[theme=rose]:bg-rose-600"
>
<RadioGroupItem
id={accent.value}
value={accent.value}
aria-label={accent.name}
className="peer sr-only"
/>
<CheckIcon className="hidden size-4 stroke-white peer-data-[state=checked]:block" />
</Label>
))}
</RadioGroup>
</FieldSet>
</Field>
<FieldSeparator />
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="number-of-gpus-f6l">Number of GPUs</FieldLabel>
@@ -91,10 +129,9 @@ export function AppearanceSettings() {
<ButtonGroup>
<Input
id="number-of-gpus-f6l"
value={gpuCount}
onChange={handleGpuInputChange}
placeholder="8"
size={3}
className="h-7 w-14! font-mono"
className="h-8 !w-14 font-mono"
maxLength={3}
/>
<Button
@@ -102,8 +139,6 @@ export function AppearanceSettings() {
size="icon-sm"
type="button"
aria-label="Decrement"
onClick={() => handleGpuAdjustment(-1)}
disabled={gpuCount <= 1}
>
<IconMinus />
</Button>
@@ -112,8 +147,6 @@ export function AppearanceSettings() {
size="icon-sm"
type="button"
aria-label="Increment"
onClick={() => handleGpuAdjustment(1)}
disabled={gpuCount >= 99}
>
<IconPlus />
</Button>

View File

@@ -6,15 +6,15 @@ import {
ArrowLeftIcon,
CalendarPlusIcon,
ClockIcon,
ListFilterIcon,
ListFilterPlusIcon,
MailCheckIcon,
MoreHorizontalIcon,
TagIcon,
Trash2Icon,
} from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/registry/new-york-v4/ui/button"
import { ButtonGroup } from "@/registry/new-york-v4/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,7 +27,7 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/styles/radix-nova/ui/dropdown-menu"
} from "@/registry/new-york-v4/ui/dropdown-menu"
export function ButtonGroupDemo() {
const [label, setLabel] = React.useState("personal")
@@ -57,7 +57,7 @@ export function ButtonGroupDemo() {
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className="w-48 [--radius:1rem]">
<DropdownMenuGroup>
<DropdownMenuItem>
<MailCheckIcon />
@@ -79,7 +79,7 @@ export function ButtonGroupDemo() {
Add to Calendar
</DropdownMenuItem>
<DropdownMenuItem>
<ListFilterIcon />
<ListFilterPlusIcon />
Add to List
</DropdownMenuItem>
<DropdownMenuSub>

View File

@@ -3,19 +3,19 @@
import * as React from "react"
import { AudioLinesIcon, PlusIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/registry/new-york-v4/ui/button"
import { ButtonGroup } from "@/registry/new-york-v4/ui/button-group"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/radix-nova/ui/input-group"
} from "@/registry/new-york-v4/ui/input-group"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/radix-nova/ui/tooltip"
} from "@/registry/new-york-v4/ui/tooltip"
export function ButtonGroupInputGroup() {
const [voiceEnabled, setVoiceEnabled] = React.useState(false)

View File

@@ -2,8 +2,8 @@
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/registry/new-york-v4/ui/button"
import { ButtonGroup } from "@/registry/new-york-v4/ui/button-group"
export function ButtonGroupNested() {
return (

View File

@@ -1,14 +1,14 @@
import { BotIcon, ChevronDownIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
import { Button } from "@/registry/new-york-v4/ui/button"
import { ButtonGroup } from "@/registry/new-york-v4/ui/button-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/radix-nova/ui/popover"
import { Separator } from "@/styles/radix-nova/ui/separator"
import { Textarea } from "@/styles/radix-nova/ui/textarea"
} from "@/registry/new-york-v4/ui/popover"
import { Separator } from "@/registry/new-york-v4/ui/separator"
import { Textarea } from "@/registry/new-york-v4/ui/textarea"
export function ButtonGroupPopover() {
return (
@@ -22,7 +22,7 @@ export function ButtonGroupPopover() {
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="gap-0 rounded-xl p-0 text-sm">
<PopoverContent align="end" className="rounded-xl p-0 text-sm">
<div className="px-4 py-3">
<div className="text-sm font-medium">Agent Tasks</div>
</div>

View File

@@ -3,10 +3,9 @@ import { PlusIcon } from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@/styles/radix-nova/ui/avatar"
import { Button } from "@/styles/radix-nova/ui/button"
} from "@/registry/new-york-v4/ui/avatar"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Empty,
EmptyContent,
@@ -14,14 +13,14 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/radix-nova/ui/empty"
} from "@/registry/new-york-v4/ui/empty"
export function EmptyAvatarGroup() {
return (
<Empty className="flex-none border py-10">
<Empty className="flex-none border">
<EmptyHeader>
<EmptyMedia>
<AvatarGroup className="grayscale">
<div className="*:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:size-12 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:grayscale">
<Avatar>
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
<AvatarFallback>CN</AvatarFallback>
@@ -40,7 +39,7 @@ export function EmptyAvatarGroup() {
/>
<AvatarFallback>ER</AvatarFallback>
</Avatar>
</AvatarGroup>
</div>
</EmptyMedia>
<EmptyTitle>No Team Members</EmptyTitle>
<EmptyDescription>

View File

@@ -1,5 +1,5 @@
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Field, FieldLabel } from "@/styles/radix-nova/ui/field"
import { Checkbox } from "@/registry/new-york-v4/ui/checkbox"
import { Field, FieldLabel } from "@/registry/new-york-v4/ui/field"
export function FieldCheckbox() {
return (

View File

@@ -1,5 +1,5 @@
import { Button } from "@/styles/radix-nova/ui/button"
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Checkbox } from "@/registry/new-york-v4/ui/checkbox"
import {
Field,
FieldDescription,
@@ -8,21 +8,20 @@ import {
FieldLegend,
FieldSeparator,
FieldSet,
} from "@/styles/radix-nova/ui/field"
import { Input } from "@/styles/radix-nova/ui/input"
} from "@/registry/new-york-v4/ui/field"
import { Input } from "@/registry/new-york-v4/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/styles/radix-nova/ui/select"
import { Textarea } from "@/styles/radix-nova/ui/textarea"
} from "@/registry/new-york-v4/ui/select"
import { Textarea } from "@/registry/new-york-v4/ui/textarea"
export function FieldDemo() {
return (
<div className="w-full max-w-md rounded-xl border p-6">
<div className="w-full max-w-md rounded-lg border p-6">
<form>
<FieldGroup>
<FieldSet>
@@ -70,20 +69,18 @@ export function FieldDemo() {
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="01">01</SelectItem>
<SelectItem value="02">02</SelectItem>
<SelectItem value="03">03</SelectItem>
<SelectItem value="04">04</SelectItem>
<SelectItem value="05">05</SelectItem>
<SelectItem value="06">06</SelectItem>
<SelectItem value="07">07</SelectItem>
<SelectItem value="08">08</SelectItem>
<SelectItem value="09">09</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="11">11</SelectItem>
<SelectItem value="12">12</SelectItem>
</SelectGroup>
<SelectItem value="01">01</SelectItem>
<SelectItem value="02">02</SelectItem>
<SelectItem value="03">03</SelectItem>
<SelectItem value="04">04</SelectItem>
<SelectItem value="05">05</SelectItem>
<SelectItem value="06">06</SelectItem>
<SelectItem value="07">07</SelectItem>
<SelectItem value="08">08</SelectItem>
<SelectItem value="09">09</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="11">11</SelectItem>
<SelectItem value="12">12</SelectItem>
</SelectContent>
</Select>
</Field>
@@ -96,14 +93,12 @@ export function FieldDemo() {
<SelectValue placeholder="YYYY" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="2024">2024</SelectItem>
<SelectItem value="2025">2025</SelectItem>
<SelectItem value="2026">2026</SelectItem>
<SelectItem value="2027">2027</SelectItem>
<SelectItem value="2028">2028</SelectItem>
<SelectItem value="2029">2029</SelectItem>
</SelectGroup>
<SelectItem value="2024">2024</SelectItem>
<SelectItem value="2025">2025</SelectItem>
<SelectItem value="2026">2026</SelectItem>
<SelectItem value="2027">2027</SelectItem>
<SelectItem value="2028">2028</SelectItem>
<SelectItem value="2029">2029</SelectItem>
</SelectContent>
</Select>
</Field>

View File

@@ -1,5 +1,5 @@
import { Card, CardContent } from "@/styles/radix-nova/ui/card"
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Card, CardContent } from "@/registry/new-york-v4/ui/card"
import { Checkbox } from "@/registry/new-york-v4/ui/checkbox"
import {
Field,
FieldDescription,
@@ -8,7 +8,7 @@ import {
FieldLegend,
FieldSet,
FieldTitle,
} from "@/styles/radix-nova/ui/field"
} from "@/registry/new-york-v4/ui/field"
const options = [
{
@@ -46,11 +46,11 @@ export function FieldHear() {
<FieldLabel
htmlFor={option.value}
key={option.value}
className="w-fit!"
className="!w-fit"
>
<Field
orientation="horizontal"
className="gap-1.5 overflow-hidden px-3! py-1.5! transition-all duration-100 ease-linear group-has-data-[state=checked]/field-label:px-2!"
className="gap-1.5 overflow-hidden !px-3 !py-1.5 transition-all duration-100 ease-linear group-has-data-[state=checked]/field-label:!px-2"
>
<Checkbox
value={option.value}

View File

@@ -6,8 +6,8 @@ import {
Field,
FieldDescription,
FieldTitle,
} from "@/styles/radix-nova/ui/field"
import { Slider } from "@/styles/radix-nova/ui/slider"
} from "@/registry/new-york-v4/ui/field"
import { Slider } from "@/registry/new-york-v4/ui/slider"
export function FieldSlider() {
const [value, setValue] = useState([200, 800])

View File

@@ -1,4 +1,4 @@
import { FieldSeparator } from "@/styles/radix-nova/ui/field"
import { FieldSeparator } from "@/registry/new-york-v4/ui/field"
import { AppearanceSettings } from "./appearance-settings"
import { ButtonGroupDemo } from "./button-group-demo"
@@ -19,7 +19,7 @@ import { SpinnerEmpty } from "./spinner-empty"
export function RootComponents() {
return (
<div className="mx-auto grid gap-8 py-1 theme-container md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-6 2xl:gap-8">
<div className="theme-container mx-auto grid gap-8 py-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-6 2xl:gap-8">
<div className="flex flex-col gap-6 *:[div]:w-full *:[div]:max-w-full">
<FieldDemo />
</div>
@@ -33,7 +33,7 @@ export function RootComponents() {
<div className="flex flex-col gap-6 *:[div]:w-full *:[div]:max-w-full">
<InputGroupButtonExample />
<ItemDemo />
<FieldSeparator className="my-4">Appearance Settings</FieldSeparator>
<FieldSeparator>Appearance Settings</FieldSeparator>
<AppearanceSettings />
</div>
<div className="order-first flex flex-col gap-6 lg:hidden xl:order-last xl:flex *:[div]:w-full *:[div]:max-w-full">

View File

@@ -8,13 +8,13 @@ import {
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/radix-nova/ui/input-group"
import { Label } from "@/styles/radix-nova/ui/label"
} from "@/registry/new-york-v4/ui/input-group"
import { Label } from "@/registry/new-york-v4/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/radix-nova/ui/popover"
} from "@/registry/new-york-v4/ui/popover"
export function InputGroupButtonExample() {
const [isFavorite, setIsFavorite] = React.useState(false)
@@ -25,7 +25,7 @@ export function InputGroupButtonExample() {
Input Secure
</Label>
<InputGroup className="[--radius:9999px]">
<InputGroupInput id="input-secure-19" className="pl-0.5!" />
<InputGroupInput id="input-secure-19" className="!pl-0.5" />
<Popover>
<PopoverTrigger asChild>
<InputGroupAddon>
@@ -47,7 +47,7 @@ export function InputGroupButtonExample() {
<p>You should not enter any sensitive information on this site.</p>
</PopoverContent>
</Popover>
<InputGroupAddon className="pl-1! text-muted-foreground">
<InputGroupAddon className="text-muted-foreground !pl-1">
https://
</InputGroupAddon>
<InputGroupAddon align="inline-end">

View File

@@ -6,7 +6,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/radix-nova/ui/dropdown-menu"
} from "@/registry/new-york-v4/ui/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
@@ -14,13 +14,13 @@ import {
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from "@/styles/radix-nova/ui/input-group"
import { Separator } from "@/styles/radix-nova/ui/separator"
} from "@/registry/new-york-v4/ui/input-group"
import { Separator } from "@/registry/new-york-v4/ui/separator"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/radix-nova/ui/tooltip"
} from "@/registry/new-york-v4/ui/tooltip"
export function InputGroupDemo() {
return (
@@ -33,7 +33,7 @@ export function InputGroupDemo() {
<InputGroupAddon align="inline-end">12 results</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput placeholder="example.com" className="pl-1!" />
<InputGroupInput placeholder="example.com" className="!pl-1" />
<InputGroupAddon>
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
@@ -67,14 +67,18 @@ export function InputGroupDemo() {
<DropdownMenuTrigger asChild>
<InputGroupButton variant="ghost">Auto</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
<DropdownMenuContent
side="top"
align="start"
className="[--radius:0.95rem]"
>
<DropdownMenuItem>Auto</DropdownMenuItem>
<DropdownMenuItem>Agent</DropdownMenuItem>
<DropdownMenuItem>Manual</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<InputGroupText className="ml-auto">52% used</InputGroupText>
<Separator orientation="vertical" className="h-4!" />
<Separator orientation="vertical" className="!h-4" />
<InputGroupButton
variant="default"
className="rounded-full"
@@ -88,8 +92,8 @@ export function InputGroupDemo() {
<InputGroup>
<InputGroupInput placeholder="@shadcn" />
<InputGroupAddon align="inline-end">
<div className="flex size-4 items-center justify-center rounded-full bg-primary text-foreground">
<IconCheck className="size-3 text-background" />
<div className="bg-primary text-foreground flex size-4 items-center justify-center rounded-full">
<IconCheck className="size-3 text-white" />
</div>
</InputGroupAddon>
</InputGroup>

View File

@@ -42,7 +42,7 @@ export function ItemAvatar() {
</Item>
<Item variant="outline">
<ItemMedia>
<div className="flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background *:data-[slot=avatar]:grayscale">
<div className="*:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:grayscale">
<Avatar className="hidden sm:flex">
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
<AvatarFallback>CN</AvatarFallback>

View File

@@ -1,6 +1,6 @@
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
import { Button } from "@/styles/radix-nova/ui/button"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Item,
ItemActions,
@@ -8,7 +8,7 @@ import {
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/styles/radix-nova/ui/item"
} from "@/registry/new-york-v4/ui/item"
export function ItemDemo() {
return (

View File

@@ -17,8 +17,8 @@ import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/styles/radix-nova/ui/avatar"
import { Badge } from "@/styles/radix-nova/ui/badge"
} from "@/registry/new-york-v4/ui/avatar"
import { Badge } from "@/registry/new-york-v4/ui/badge"
import {
Command,
CommandEmpty,
@@ -26,7 +26,7 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "@/styles/radix-nova/ui/command"
} from "@/registry/new-york-v4/ui/command"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -39,25 +39,25 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/styles/radix-nova/ui/dropdown-menu"
import { Field, FieldLabel } from "@/styles/radix-nova/ui/field"
} from "@/registry/new-york-v4/ui/dropdown-menu"
import { Field, FieldLabel } from "@/registry/new-york-v4/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/styles/radix-nova/ui/input-group"
} from "@/registry/new-york-v4/ui/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/radix-nova/ui/popover"
import { Switch } from "@/styles/radix-nova/ui/switch"
} from "@/registry/new-york-v4/ui/popover"
import { Switch } from "@/registry/new-york-v4/ui/switch"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/radix-nova/ui/tooltip"
} from "@/registry/new-york-v4/ui/tooltip"
const SAMPLE_DATA = {
mentionable: [
@@ -190,17 +190,17 @@ export function NotionPromptForm() {
const hasMentions = mentions.length > 0
return (
<form>
<form className="[--radius:1.2rem]">
<Field>
<FieldLabel htmlFor="notion-prompt" className="sr-only">
Prompt
</FieldLabel>
<InputGroup className="rounded-xl">
<InputGroup>
<InputGroupTextarea
id="notion-prompt"
placeholder="Ask, search, or make anything..."
/>
<InputGroupAddon align="block-start" className="pt-3">
<InputGroupAddon align="block-start">
<Popover
open={mentionPopoverOpen}
onOpenChange={setMentionPopoverOpen}
@@ -214,7 +214,7 @@ export function NotionPromptForm() {
<InputGroupButton
variant="outline"
size={!hasMentions ? "sm" : "icon-sm"}
className="transition-transform"
className="rounded-full transition-transform"
>
<IconAt /> {!hasMentions && "Add context"}
</InputGroupButton>
@@ -222,7 +222,7 @@ export function NotionPromptForm() {
</TooltipTrigger>
<TooltipContent>Mention a person, page, or date</TooltipContent>
</Tooltip>
<PopoverContent className="p-0" align="start">
<PopoverContent className="p-0 [--radius:1.2rem]" align="start">
<Command>
<CommandInput placeholder="Search pages..." />
<CommandList>
@@ -240,7 +240,6 @@ export function NotionPromptForm() {
setMentions((prev) => [...prev, currentValue])
setMentionPopoverOpen(false)
}}
className="rounded-lg"
>
<MentionableIcon item={item} />
{item.title}
@@ -252,7 +251,7 @@ export function NotionPromptForm() {
</Command>
</PopoverContent>
</Popover>
<div className="-m-1.5 no-scrollbar flex gap-1 overflow-y-auto p-1.5">
<div className="no-scrollbar -m-1.5 flex gap-1 overflow-y-auto p-1.5">
{mentions.map((mention) => {
const item = SAMPLE_DATA.mentionable.find(
(item) => item.title === mention
@@ -267,7 +266,7 @@ export function NotionPromptForm() {
key={mention}
size="sm"
variant="secondary"
className="rounded-full pl-2!"
className="rounded-full !pl-2"
onClick={() => {
setMentions((prev) => prev.filter((m) => m !== mention))
}}
@@ -310,10 +309,10 @@ export function NotionPromptForm() {
<DropdownMenuContent
side="top"
align="start"
className="min-w-48"
className="[--radius:1rem]"
>
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
<DropdownMenuGroup className="w-42">
<DropdownMenuLabel className="text-muted-foreground text-xs">
Select Agent Mode
</DropdownMenuLabel>
{SAMPLE_DATA.models.map((model) => (
@@ -347,7 +346,11 @@ export function NotionPromptForm() {
<IconWorld /> All Sources
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" className="w-72">
<DropdownMenuContent
side="top"
align="end"
className="[--radius:1rem]"
>
<DropdownMenuGroup>
<DropdownMenuItem
asChild
@@ -431,7 +434,7 @@ export function NotionPromptForm() {
<DropdownMenuItem>
<IconPlus /> Connect Apps
</DropdownMenuItem>
<DropdownMenuLabel className="text-xs text-muted-foreground">
<DropdownMenuLabel className="text-muted-foreground text-xs">
We&apos;ll only search in the sources selected here.
</DropdownMenuLabel>
</DropdownMenuGroup>

View File

@@ -1,9 +1,9 @@
import { Badge } from "@/styles/radix-nova/ui/badge"
import { Spinner } from "@/styles/radix-nova/ui/spinner"
import { Badge } from "@/registry/new-york-v4/ui/badge"
import { Spinner } from "@/registry/new-york-v4/ui/spinner"
export function SpinnerBadge() {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 [--radius:1.2rem]">
<Badge>
<Spinner />
Syncing

View File

@@ -1,4 +1,4 @@
import { Button } from "@/styles/radix-nova/ui/button"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Empty,
EmptyContent,
@@ -6,8 +6,8 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/radix-nova/ui/empty"
import { Spinner } from "@/styles/radix-nova/ui/spinner"
} from "@/registry/new-york-v4/ui/empty"
import { Spinner } from "@/registry/new-york-v4/ui/spinner"
export function SpinnerEmpty() {
return (

View File

@@ -1,4 +1,4 @@
import { type Metadata } from "next"
import { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
@@ -55,21 +55,21 @@ export default function IndexPage() {
<PageHeaderHeading className="max-w-4xl">{title}</PageHeaderHeading>
<PageHeaderDescription>{description}</PageHeaderDescription>
<PageActions>
<Button asChild size="sm" className="h-[31px] rounded-lg">
<Link href="/create">New Project</Link>
<Button asChild size="sm">
<Link href="/docs/installation">Get Started</Link>
</Button>
<Button asChild size="sm" variant="ghost" className="rounded-lg">
<Button asChild size="sm" variant="ghost">
<Link href="/docs/components">View Components</Link>
</Button>
</PageActions>
</PageHeader>
<PageNav className="hidden md:flex">
<ExamplesNav className="flex-1 overflow-hidden [&>a:first-child]:text-primary" />
<ExamplesNav className="[&>a:first-child]:text-primary flex-1 overflow-hidden" />
<ThemeSelector className="mr-4 hidden md:flex" />
</PageNav>
<div className="container-wrapper flex-1 section-soft pb-6">
<div className="container-wrapper section-soft flex-1 pb-6">
<div className="container overflow-hidden">
<section className="-mx-4 w-[160vw] overflow-hidden rounded-lg border border-border/50 md:hidden md:w-[150vw]">
<section className="border-border/50 -mx-4 w-[160vw] overflow-hidden rounded-lg border md:hidden md:w-[150vw]">
<Image
src="/r/styles/new-york-v4/dashboard-01-light.png"
width={1400}
@@ -87,7 +87,7 @@ export default function IndexPage() {
priority
/>
</section>
<section className="hidden theme-container md:block">
<section className="theme-container hidden md:block">
<RootComponents />
</section>
</div>

View File

@@ -1,7 +1,6 @@
import { getAllBlockIds } from "@/lib/blocks"
import { registryCategories } from "@/lib/categories"
import { BlockDisplay } from "@/components/block-display"
import { getActiveStyle } from "@/registry/_legacy-styles"
import { registryCategories } from "@/registry/registry-categories"
export const revalidate = false
export const dynamic = "force-static"
@@ -18,16 +17,13 @@ export default async function BlocksPage({
}: {
params: Promise<{ categories?: string[] }>
}) {
const [{ categories = [] }, activeStyle] = await Promise.all([
params,
getActiveStyle(),
])
const { categories = [] } = await params
const blocks = await getAllBlockIds(["registry:block"], categories)
return (
<div className="flex flex-col gap-12 md:gap-24">
{blocks.map((name) => (
<BlockDisplay name={name} key={name} styleName={activeStyle.name} />
<BlockDisplay name={name} key={name} />
))}
</div>
)

View File

@@ -1,4 +1,4 @@
import { type Metadata } from "next"
import { Metadata } from "next"
import Link from "next/link"
import { Announcement } from "@/components/announcement"
@@ -56,7 +56,7 @@ export default function BlocksLayout({
<a href="#blocks">Browse Blocks</a>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/docs/components">View Components</Link>
<Link href="/docs/blocks">Add a block</Link>
</Button>
</PageActions>
</PageHeader>
@@ -71,7 +71,7 @@ export default function BlocksLayout({
<Link href="/blocks/sidebar">Browse all blocks</Link>
</Button>
</PageNav>
<div className="container-wrapper flex-1 section-soft md:py-12">
<div className="container-wrapper section-soft flex-1 md:py-12">
<div className="container">{children}</div>
</div>
</>

View File

@@ -1,7 +1,6 @@
import Link from "next/link"
import { BlockDisplay } from "@/components/block-display"
import { getActiveStyle } from "@/registry/_legacy-styles"
import { Button } from "@/registry/new-york-v4/ui/button"
export const dynamic = "force-static"
@@ -16,12 +15,10 @@ const FEATURED_BLOCKS = [
]
export default async function BlocksPage() {
const activeStyle = await getActiveStyle()
return (
<div className="flex flex-col gap-12 md:gap-24">
{FEATURED_BLOCKS.map((name) => (
<BlockDisplay name={name} key={name} styleName={activeStyle.name} />
<BlockDisplay name={name} key={name} />
))}
<div className="container-wrapper">
<div className="container flex justify-center py-6">

View File

@@ -2,12 +2,7 @@ import * as React from "react"
import { notFound } from "next/navigation"
import { cn } from "@/lib/utils"
import {
ChartDisplay,
getCachedRegistryItem,
getChartHighlightedCode,
} from "@/components/chart-display"
import { getActiveStyle } from "@/registry/_legacy-styles"
import { ChartDisplay } from "@/components/chart-display"
import { charts } from "@/app/(app)/charts/charts"
export const revalidate = false
@@ -46,27 +41,6 @@ export default async function ChartPage({ params }: ChartPageProps) {
const chartType = type as ChartType
const chartList = charts[chartType]
const activeStyle = await getActiveStyle()
// Prefetch all chart data in parallel for better performance.
// Charts are rendered via iframes, so we only need the metadata and highlighted code.
const chartDataPromises = chartList.map(async (chart) => {
const registryItem = await getCachedRegistryItem(chart.id, activeStyle.name)
if (!registryItem) return null
const highlightedCode = await getChartHighlightedCode(
registryItem.files?.[0]?.content ?? ""
)
if (!highlightedCode) return null
return {
...registryItem,
highlightedCode,
fullWidth: chart.fullWidth,
}
})
const prefetchedCharts = await Promise.all(chartDataPromises)
return (
<div className="grid flex-1 gap-12 lg:gap-24">
@@ -75,14 +49,15 @@ export default async function ChartPage({ params }: ChartPageProps) {
</h2>
<div className="grid flex-1 scroll-mt-20 items-stretch gap-10 md:grid-cols-2 md:gap-6 lg:grid-cols-3 xl:gap-10">
{Array.from({ length: 12 }).map((_, index) => {
const chart = prefetchedCharts[index]
const chart = chartList[index]
return chart ? (
<ChartDisplay
key={chart.name}
chart={chart}
style={activeStyle.name}
key={chart.id}
name={chart.id}
className={cn(chart.fullWidth && "md:col-span-2 lg:col-span-3")}
/>
>
<chart.component />
</ChartDisplay>
) : (
<div
key={`empty-${index}`}

View File

@@ -1,4 +1,4 @@
import type * as React from "react"
import * as React from "react"
import { ChartAreaAxes } from "@/registry/new-york-v4/charts/chart-area-axes"
import { ChartAreaDefault } from "@/registry/new-york-v4/charts/chart-area-default"

View File

@@ -1,4 +1,4 @@
import { type Metadata } from "next"
import { Metadata } from "next"
import Link from "next/link"
import { Announcement } from "@/components/announcement"
@@ -63,8 +63,9 @@ export default function ChartsLayout({
</PageHeader>
<PageNav id="charts">
<ChartsNav />
<ThemeSelector className="mr-4 hidden md:flex" />
</PageNav>
<div className="container-wrapper flex-1">
<div className="container-wrapper section-soft flex-1">
<div className="container pb-6">
<section className="theme-container">{children}</section>
</div>

View File

@@ -1,4 +1,4 @@
import { type Metadata } from "next"
import { Metadata } from "next"
import Link from "next/link"
import { Announcement } from "@/components/announcement"
@@ -62,7 +62,7 @@ export default function ColorsLayout({
<div className="hidden">
<div className="container-wrapper">
<div className="container flex items-center justify-between gap-8 py-4">
<ColorsNav className="flex-1 overflow-hidden [&>a:first-child]:text-primary" />
<ColorsNav className="[&>a:first-child]:text-primary flex-1 overflow-hidden" />
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,104 +0,0 @@
"use client"
import * as React from "react"
import { type RegistryItem } from "shadcn/schema"
import { useIsMobile } from "@/hooks/use-mobile"
import { getThemesForBaseColor, STYLES } from "@/registry/config"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-nova/ui/card"
import { FieldGroup, FieldSeparator } from "@/styles/base-nova/ui/field"
import { MenuAccentPicker } from "@/app/(app)/create/components/accent-picker"
import { ActionMenu } from "@/app/(app)/create/components/action-menu"
import { BaseColorPicker } from "@/app/(app)/create/components/base-color-picker"
import { BasePicker } from "@/app/(app)/create/components/base-picker"
import { ChartColorPicker } from "@/app/(app)/create/components/chart-color-picker"
import { CopyPreset } from "@/app/(app)/create/components/copy-preset"
import { FontPicker } from "@/app/(app)/create/components/font-picker"
import { IconLibraryPicker } from "@/app/(app)/create/components/icon-library-picker"
import { MainMenu } from "@/app/(app)/create/components/main-menu"
import { MenuColorPicker } from "@/app/(app)/create/components/menu-picker"
import { RadiusPicker } from "@/app/(app)/create/components/radius-picker"
import { RandomButton } from "@/app/(app)/create/components/random-button"
import { ResetDialog } from "@/app/(app)/create/components/reset-button"
import { StylePicker } from "@/app/(app)/create/components/style-picker"
import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
import { V0Button } from "@/app/(app)/create/components/v0-button"
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
export function Customizer({
itemsByBase,
}: {
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
}) {
const [params] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const anchorRef = React.useRef<HTMLDivElement | null>(null)
const availableThemes = React.useMemo(
() => getThemesForBaseColor(params.baseColor),
[params.baseColor]
)
return (
<Card
className="dark top-24 right-12 isolate z-10 max-h-full min-h-0 w-full self-start rounded-2xl bg-card/90 shadow-xl backdrop-blur-xl md:w-(--customizer-width)"
ref={anchorRef}
size="sm"
>
<CardHeader className="hidden items-center justify-between gap-2 border-b group-data-reversed/layout:flex-row-reverse md:flex">
<MainMenu />
</CardHeader>
<CardContent className="no-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden md:overflow-y-auto">
<FieldGroup className="flex-row gap-2.5 py-px **:data-[slot=field-separator]:-mx-4 **:data-[slot=field-separator]:w-auto md:flex-col md:gap-3.25">
<StylePicker
styles={STYLES}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FieldSeparator className="hidden md:block" />
<BaseColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<ThemePicker
themes={availableThemes}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<ChartColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<FieldSeparator className="hidden md:block" />
<FontPicker
label="Heading"
param="fontHeading"
fonts={FONT_HEADING_OPTIONS}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FontPicker
label="Font"
param="font"
fonts={FONTS}
isMobile={isMobile}
anchorRef={anchorRef}
/>
<FieldSeparator className="hidden md:block" />
<IconLibraryPicker isMobile={isMobile} anchorRef={anchorRef} />
<RadiusPicker isMobile={isMobile} anchorRef={anchorRef} />
<FieldSeparator className="hidden md:block" />
<MenuColorPicker isMobile={isMobile} anchorRef={anchorRef} />
<MenuAccentPicker isMobile={isMobile} anchorRef={anchorRef} />
{isMobile && <BasePicker isMobile={isMobile} anchorRef={anchorRef} />}
</FieldGroup>
</CardContent>
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:**:[button,a]:w-full">
<CopyPreset className="flex-1 md:flex-none" />
<RandomButton className="flex-1 md:flex-none" />
<ActionMenu itemsByBase={itemsByBase} />
<ResetDialog />
</CardFooter>
</Card>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,154 +0,0 @@
"use client"
import * as React from "react"
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/create/components/action-menu"
import {
REDO_FORWARD_TYPE,
UNDO_FORWARD_TYPE,
} from "@/app/(app)/create/components/history-buttons"
import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/create/components/mode-switcher"
import { PreviewSwitcher } from "@/app/(app)/create/components/preview-switcher"
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/create/components/random-button"
import { sendToIframe } from "@/app/(app)/create/hooks/use-iframe-sync"
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
import {
serializeDesignSystemSearchParams,
useDesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
// Hoisted — only uses module-level constants, no component state. (rendering-hoist-jsx)
function handleMessage(event: MessageEvent) {
if (
typeof window === "undefined" ||
event.origin !== window.location.origin
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "R",
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
export function Preview() {
const [params] = useDesignSystemSearchParams()
const iframeRef = React.useRef<HTMLIFrameElement>(null)
React.useEffect(() => {
const iframe = iframeRef.current
if (!iframe) {
return
}
const sendParams = () => {
sendToIframe(iframe, "design-system-params", params)
}
if (iframe.contentWindow) {
sendParams()
}
iframe.addEventListener("load", sendParams)
return () => {
iframe.removeEventListener("load", sendParams)
}
}, [params])
React.useEffect(() => {
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [])
const iframeSrc = React.useMemo(() => {
// The iframe src needs to include the serialized design system params
// for the initial load, but not be reactive to them as it would cause
// full-iframe reloads on every param change (flashes & loss of state).
// Further updates of the search params will be sent to the iframe
// via a postMessage channel, for it to sync its own history onto the host's.
return serializeDesignSystemSearchParams(
`/preview/${params.base}/${params.item}`,
params
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.base, params.item])
return (
<div className="relative flex flex-1 flex-col justify-center overflow-hidden rounded-2xl ring ring-foreground/10 md:ring-muted dark:ring-foreground/10">
<div className="relative z-0 mx-auto flex w-full flex-1 flex-col overflow-hidden">
<div className="absolute inset-0 bg-muted dark:bg-muted/30" />
<iframe
key={params.base + params.item}
ref={iframeRef}
src={iframeSrc}
className="z-10 size-full flex-1"
title="Preview"
/>
</div>
<PreviewSwitcher />
</div>
)
}

View File

@@ -1,375 +0,0 @@
"use client"
import * as React from "react"
import { Copy01Icon, Globe02Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/lib/utils"
import { useConfig } from "@/hooks/use-config"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { BASES, type BaseName } from "@/registry/config"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Field,
FieldContent,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/styles/base-nova/ui/field"
import { RadioGroup, RadioGroupItem } from "@/styles/base-nova/ui/radio-group"
import { Switch } from "@/styles/base-nova/ui/switch"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/styles/base-nova/ui/tabs"
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
import {
useDesignSystemSearchParams,
type DesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
import {
getFramework,
getTemplateValue,
NO_MONOREPO_FRAMEWORKS,
TEMPLATES,
} from "@/app/(app)/create/lib/templates"
const TURBOREPO_LOGO =
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Turborepo</title><path d="M11.9906 4.1957c-4.2998 0-7.7981 3.501-7.7981 7.8043s3.4983 7.8043 7.7981 7.8043c4.2999 0 7.7982-3.501 7.7982-7.8043s-3.4983-7.8043-7.7982-7.8043m0 11.843c-2.229 0-4.0356-1.8079-4.0356-4.0387s1.8065-4.0387 4.0356-4.0387S16.0262 9.7692 16.0262 12s-1.8065 4.0388-4.0356 4.0388m.6534-13.1249V0C18.9726.3386 24 5.5822 24 12s-5.0274 11.66-11.356 12v-2.9139c4.7167-.3372 8.4516-4.2814 8.4516-9.0861s-3.735-8.749-8.4516-9.0861M5.113 17.9586c-1.2502-1.4446-2.0562-3.2845-2.2-5.3046H0c.151 2.8266 1.2808 5.3917 3.051 7.3668l2.0606-2.0622zM11.3372 24v-2.9139c-2.02-.1439-3.8584-.949-5.3019-2.2018l-2.0606 2.0623c1.975 1.773 4.538 2.9022 7.361 3.0534z"/></svg>'
const ORIGIN = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"
const IS_LOCAL_DEV = ORIGIN.includes("localhost")
const SHADCN_VERSION = process.env.NEXT_PUBLIC_RC ? "@rc" : "@latest"
const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"] as const
type PackageManager = (typeof PACKAGE_MANAGERS)[number]
export function ProjectForm({
className,
}: React.ComponentProps<typeof Button>) {
const [open, setOpen] = React.useState(false)
const [params, setParams] = useDesignSystemSearchParams()
const presetCode = usePresetCode()
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
const packageManager = (config.packageManager || "pnpm") as PackageManager
const framework = React.useMemo(
() => getFramework(params.template ?? "next"),
[params.template]
)
const isMonorepo = React.useMemo(
() => params.template?.endsWith("-monorepo") ?? false,
[params.template]
)
const hasMonorepo = !NO_MONOREPO_FRAMEWORKS.includes(
framework as (typeof NO_MONOREPO_FRAMEWORKS)[number]
)
const commands = React.useMemo(() => {
const presetFlag = ` --preset ${presetCode}`
const baseFlag = params.base !== "radix" ? ` --base ${params.base}` : ""
const templateFlag = ` --template ${framework}`
const monorepoFlag = isMonorepo ? " --monorepo" : ""
const rtlFlag = params.rtl ? " --rtl" : ""
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}`
return IS_LOCAL_DEV
? {
pnpm: `shadcn init${flags}`,
npm: `shadcn init${flags}`,
yarn: `shadcn init${flags}`,
bun: `shadcn init${flags}`,
}
: {
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} init${flags}`,
npm: `npx shadcn${SHADCN_VERSION} init${flags}`,
yarn: `yarn dlx shadcn${SHADCN_VERSION} init${flags}`,
bun: `bunx --bun shadcn${SHADCN_VERSION} init${flags}`,
}
}, [framework, isMonorepo, params.base, params.rtl, presetCode])
const command = commands[packageManager]
React.useEffect(() => {
if (hasCopied) {
const timer = setTimeout(() => setHasCopied(false), 2000)
return () => clearTimeout(timer)
}
}, [hasCopied])
const handleCopy = React.useCallback(() => {
const properties: Record<string, string> = {
command,
}
if (params.template) {
properties.template = params.template
}
copyToClipboardWithMeta(command, {
name: "copy_npm_command",
properties,
})
setHasCopied(true)
}, [command, params.template])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button className={cn(className)} />}>
Create Project
</DialogTrigger>
<DialogContent className="dark no-scrollbar max-h-[calc(100svh-2rem)] overflow-y-auto rounded-2xl p-6 shadow-xl **:data-[slot=field-separator]:h-2 sm:max-w-sm">
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription>
Pick a template and configure your project.
</DialogDescription>
</DialogHeader>
<div>
<FieldGroup>
<FieldSeparator className="-mx-6" />
<Field className="-mt-2 gap-3">
<FieldLabel>Template</FieldLabel>
<TemplateGrid template={params.template} setParams={setParams} />
</Field>
<FieldSeparator className="-mx-6" />
<Field className="-mt-2">
<FieldLabel>Base</FieldLabel>
<BaseGrid base={params.base} setParams={setParams} />
</Field>
<FieldSeparator className="-mx-6" />
<FieldSet>
<FieldLegend variant="label" className="sr-only">
Options
</FieldLegend>
<Field
orientation="horizontal"
data-disabled={hasMonorepo ? undefined : "true"}
>
<FieldLabel htmlFor="monorepo">
<span
className="size-4 text-neutral-100 [&_svg]:size-4 [&_svg]:fill-current"
dangerouslySetInnerHTML={{
__html: TURBOREPO_LOGO,
}}
/>
Create a monorepo
</FieldLabel>
<Switch
id="monorepo"
checked={params.template?.endsWith("-monorepo") ?? false}
disabled={!hasMonorepo}
onCheckedChange={(checked) => {
const framework = getFramework(params.template ?? "next")
setParams({
template: getTemplateValue(
framework,
checked === true
) as typeof params.template,
})
}}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field orientation="horizontal">
<FieldLabel htmlFor="rtl">
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
Enable RTL support
</FieldLabel>
<Switch
id="rtl"
checked={params.rtl}
onCheckedChange={(checked) =>
setParams({ rtl: checked === true })
}
/>
</Field>
</FieldSet>
</FieldGroup>
</div>
<DialogFooter className="-mx-6 -mb-6 min-w-0">
<div className="flex w-full min-w-0 flex-col gap-3">
<Tabs
value={packageManager}
onValueChange={(value) => {
setConfig((prev) => ({
...prev,
packageManager: value as PackageManager,
}))
}}
className="min-w-0 gap-0 overflow-hidden rounded-xl border-0 ring-1 ring-border"
>
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
<TabsList className="bg-transparent font-mono">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger
key={manager}
value={manager}
className="py-0 leading-none data-[state=active]:shadow-none"
>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
>
{hasCopied ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(commands).map(([key, cmd]) => {
return (
<TabsContent key={key} value={key}>
<div className="relative overflow-hidden border-t bg-popover p-3">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
</div>
</div>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopy} className="h-9 w-full">
{hasCopied ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
const TemplateGrid = React.memo(function TemplateGrid({
template,
setParams,
}: {
template: DesignSystemSearchParams["template"]
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
}) {
const isMonorepo = template?.endsWith("-monorepo") ?? false
const framework = getFramework(template ?? "next")
const handleTemplateChange = React.useCallback(
(value: string) => {
setParams({
template: getTemplateValue(
value,
isMonorepo
) as DesignSystemSearchParams["template"],
})
},
[isMonorepo, setParams]
)
return (
<RadioGroup
value={framework}
onValueChange={handleTemplateChange}
className="grid grid-cols-2 gap-2"
>
{TEMPLATES.map((item) => (
<FieldLabel
key={item.value}
htmlFor={`template-${item.value}`}
className="block w-full"
>
<Field
orientation="horizontal"
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
>
<FieldContent className="flex flex-row items-center gap-2 px-2.5 py-1.5">
<div
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
dangerouslySetInnerHTML={{
__html: item.logo,
}}
></div>
<FieldTitle>{item.title}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={item.value}
id={`template-${item.value}`}
className="sr-only absolute"
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})
const BaseGrid = React.memo(function BaseGrid({
base,
setParams,
}: {
base: DesignSystemSearchParams["base"]
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
}) {
const handleBaseChange = React.useCallback(
(value: string) => {
setParams({ base: value as BaseName })
},
[setParams]
)
return (
<RadioGroup
value={base}
onValueChange={handleBaseChange}
aria-label="Base"
className="grid grid-cols-2 gap-2"
>
{BASES.map((item) => (
<FieldLabel
key={item.name}
htmlFor={`base-${item.name}`}
className="block w-full"
>
<Field
orientation="horizontal"
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
>
<FieldContent className="flex flex-row items-center gap-2 px-2.5 py-1.5">
<div
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
dangerouslySetInnerHTML={{
__html: item.meta?.logo ?? "",
}}
/>
<FieldTitle>{item.title}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={item.name}
id={`base-${item.name}`}
className="sr-only absolute"
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,142 +0,0 @@
"use client"
import * as React from "react"
import { type RegistryItem } from "shadcn/schema"
import useSWR from "swr"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
import { groupItemsByType } from "@/app/(app)/create/lib/utils"
const ACTION_MENU_OPEN_KEY = "create:action-menu-open"
type ActionMenuItem = {
id: string
type: string
label: string
registryName: string
}
type ActionMenuGroup = {
type: string
title: string
items: ActionMenuItem[]
}
type ActionMenuSourceItem = Pick<RegistryItem, "name" | "title" | "type">
const SEARCH_KEYWORDS: Record<string, string> = {
"registry:block": "block blocks component components",
"registry:item": "item items component components",
}
function sortRegistryGroups(groups: ReturnType<typeof groupItemsByType>) {
return [...groups].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)
})
}
export function useActionMenu(
itemsByBase: Record<string, ActionMenuSourceItem[]>
) {
const [params, setParams] = useDesignSystemSearchParams()
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
ACTION_MENU_OPEN_KEY,
{
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
)
const groups = React.useMemo<ActionMenuGroup[]>(() => {
const currentBaseItems = itemsByBase?.[params.base] ?? []
const sortedRegistryGroups = sortRegistryGroups(
groupItemsByType(currentBaseItems)
)
return 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,
})),
}))
}, [itemsByBase, params.base])
const activeRegistryName = params.item
const handleSelect = React.useCallback(
(registryName: string) => {
setParams({ item: registryName })
void setOpenData(false, { revalidate: false })
},
[setOpenData, setParams]
)
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
void setOpenData(nextOpen, { revalidate: false })
},
[setOpenData]
)
const getCommandValue = React.useCallback((item: ActionMenuItem) => {
const keywords = SEARCH_KEYWORDS[item.type] ?? item.type.replace(":", " ")
return `${item.label ?? ""} ${keywords}`.trim()
}, [])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "p" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
void setOpenData((currentOpen = false) => !currentOpen, {
revalidate: false,
})
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [setOpenData])
return {
activeRegistryName,
getCommandValue,
groups,
handleSelect,
open,
setOpen: handleOpenChange,
}
}
export function useActionMenuTrigger() {
const { mutate: setOpenData } = useSWR<boolean>(ACTION_MENU_OPEN_KEY, {
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
})
const openActionMenu = React.useCallback(() => {
void setOpenData(true, { revalidate: false })
}, [setOpenData])
return {
openActionMenu,
}
}

View File

@@ -1,11 +0,0 @@
"use client"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
// Returns the canonical preset code derived from the current search params.
export function usePresetCode() {
const [params] = useDesignSystemSearchParams()
return getPresetCode(params)
}

View File

@@ -1,145 +0,0 @@
"use client"
import * as React from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
type HistoryContextValue = {
canGoBack: boolean
canGoForward: boolean
goBack: () => void
goForward: () => void
}
const HistoryContext = React.createContext<HistoryContextValue | null>(null)
export function HistoryProvider({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
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)
React.useEffect(() => {
if (isNavigatingRef.current) {
isNavigatingRef.current = false
return
}
if (preset === entriesRef.current[indexRef.current]) {
return
}
const nextEntries = entriesRef.current.slice(0, indexRef.current + 1)
nextEntries.push(preset)
entriesRef.current = nextEntries
const nextIndex = nextEntries.length - 1
indexRef.current = nextIndex
maxIndexRef.current = nextIndex
setIndex(nextIndex)
setMaxIndex(nextIndex)
}, [preset])
const canGoBack = index > 0
const canGoForward = index < maxIndex
const goBack = React.useCallback(() => {
if (indexRef.current <= 0) {
return
}
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 (indexRef.current >= maxIndexRef.current) {
return
}
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 }),
[canGoBack, canGoForward, goBack, goForward]
)
return <HistoryContext value={value}>{children}</HistoryContext>
}
export function useHistory() {
const context = React.useContext(HistoryContext)
if (!context) {
throw new Error("useHistory must be used within HistoryProvider")
}
return context
}

View File

@@ -1,69 +0,0 @@
"use client"
import * as React from "react"
import type { DesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
type ParentToIframeMessage = {
type: "design-system-params"
data: DesignSystemSearchParams
}
export const isInIframe = () => {
if (typeof window === "undefined") {
return false
}
return window.self !== window.top
}
export function useIframeMessageListener<
Message extends ParentToIframeMessage,
MessageType extends Message["type"],
>(
messageType: MessageType,
onMessage: (data: Extract<Message, { type: MessageType }>["data"]) => void
) {
const onMessageRef = React.useRef(onMessage)
React.useEffect(() => {
onMessageRef.current = onMessage
}, [onMessage])
React.useEffect(() => {
if (!isInIframe()) {
return
}
const handleMessage = (event: MessageEvent) => {
if (event.data.type === messageType) {
onMessageRef.current(event.data.data)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [messageType])
}
export function sendToIframe<
Message extends ParentToIframeMessage,
MessageType extends Message["type"],
>(
iframe: HTMLIFrameElement | null,
messageType: MessageType,
data: Extract<Message, { type: MessageType }>["data"]
) {
if (!iframe?.contentWindow) {
return
}
iframe.contentWindow.postMessage(
{
type: messageType,
data,
},
"*"
)
}

View File

@@ -1,59 +0,0 @@
"use client"
import * as React from "react"
export type LockableParam =
| "style"
| "baseColor"
| "theme"
| "chartColor"
| "iconLibrary"
| "font"
| "fontHeading"
| "menuAccent"
| "menuColor"
| "radius"
type LocksContextValue = {
locks: Set<LockableParam>
isLocked: (param: LockableParam) => boolean
toggleLock: (param: LockableParam) => void
}
const LocksContext = React.createContext<LocksContextValue | null>(null)
export function LocksProvider({ children }: { children: React.ReactNode }) {
const [locks, setLocks] = React.useState<Set<LockableParam>>(new Set())
const isLocked = React.useCallback(
(param: LockableParam) => locks.has(param),
[locks]
)
const toggleLock = React.useCallback((param: LockableParam) => {
setLocks((prev) => {
const next = new Set(prev)
if (next.has(param)) {
next.delete(param)
} else {
next.add(param)
}
return next
})
}, [])
const value = React.useMemo(
() => ({ locks, isLocked, toggleLock }),
[locks, isLocked, toggleLock]
)
return <LocksContext value={value}>{children}</LocksContext>
}
export function useLocks() {
const context = React.useContext(LocksContext)
if (!context) {
throw new Error("useLocks must be used within LocksProvider")
}
return context
}

View File

@@ -1,181 +0,0 @@
"use client"
import * as React from "react"
import {
BASE_COLORS,
getThemesForBaseColor,
iconLibraries,
MENU_ACCENTS,
MENU_COLORS,
RADII,
STYLES,
type FontHeadingValue,
} from "@/registry/config"
import { useLocks } from "@/app/(app)/create/hooks/use-locks"
import { FONTS } from "@/app/(app)/create/lib/fonts"
import {
applyBias,
RANDOMIZE_BIASES,
type RandomizeContext,
} from "@/app/(app)/create/lib/randomize-biases"
import {
isTranslucentMenuColor,
useDesignSystemSearchParams,
} from "@/app/(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 paramsRef = React.useRef(params)
React.useEffect(() => {
paramsRef.current = params
}, [params])
const randomize = React.useCallback(() => {
const selectedStyle = locks.has("style")
? paramsRef.current.style
: randomItem(STYLES).name
const context: RandomizeContext = {
style: selectedStyle,
}
const availableBaseColors = applyBias(
BASE_COLORS,
context,
RANDOMIZE_BIASES.baseColors
)
const baseColor = locks.has("baseColor")
? paramsRef.current.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")
? paramsRef.current.theme
: randomItem(availableThemes).name
context.theme = selectedTheme
const availableChartColors = applyBias(
getThemesForBaseColor(baseColor),
context,
RANDOMIZE_BIASES.chartColors
)
const selectedChartColor = locks.has("chartColor")
? paramsRef.current.chartColor
: randomItem(availableChartColors).name
context.chartColor = selectedChartColor
const selectedFont = locks.has("font")
? paramsRef.current.font
: randomItem(availableFonts).value
context.font = selectedFont
// Pick heading font: ~70% inherit, ~30% distinct with cross-category contrast.
let selectedFontHeading: FontHeadingValue
if (locks.has("fontHeading")) {
selectedFontHeading = paramsRef.current.fontHeading
} else if (Math.random() < 0.7) {
selectedFontHeading = "inherit"
} else {
const bodyType = availableFonts.find(
(f) => f.value === selectedFont
)?.type
const contrastFonts = availableFonts.filter(
(f) => f.type !== bodyType && f.value !== selectedFont
)
selectedFontHeading = (
contrastFonts.length > 0
? randomItem(contrastFonts)
: randomItem(availableFonts)
).value as FontHeadingValue
}
const selectedRadius = locks.has("radius")
? paramsRef.current.radius
: randomItem(availableRadii).name
const selectedIconLibrary = locks.has("iconLibrary")
? paramsRef.current.iconLibrary
: randomItem(Object.values(iconLibraries)).name
const lockedMenuAccent = locks.has("menuAccent")
? paramsRef.current.menuAccent
: undefined
const availableMenuColors =
!locks.has("menuColor") && lockedMenuAccent === "bold"
? MENU_COLORS.filter((menuColor) => {
return !isTranslucentMenuColor(menuColor.value)
})
: MENU_COLORS
const selectedMenuColor = locks.has("menuColor")
? paramsRef.current.menuColor
: randomItem(availableMenuColors).value
const selectedMenuAccent =
locks.has("menuAccent") || isTranslucentMenuColor(selectedMenuColor)
? paramsRef.current.menuAccent === "bold" &&
isTranslucentMenuColor(selectedMenuColor)
? "subtle"
: paramsRef.current.menuAccent
: randomItem(MENU_ACCENTS).value
context.radius = selectedRadius
const nextParams = {
style: selectedStyle,
baseColor,
theme: selectedTheme,
chartColor: selectedChartColor,
iconLibrary: selectedIconLibrary,
font: selectedFont,
fontHeading: selectedFontHeading,
menuAccent: selectedMenuAccent,
menuColor: selectedMenuColor,
radius: selectedRadius,
}
// Keep the ref in sync so rapid repeats use the latest randomized state
// even before the URL state finishes committing.
paramsRef.current = {
...paramsRef.current,
...nextParams,
}
setParams(nextParams)
}, [setParams, locks])
const randomizeRef = React.useRef(randomize)
React.useEffect(() => {
randomizeRef.current = randomize
}, [randomize])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "r" && !e.shiftKey && !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()
randomizeRef.current()
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [])
return { randomize }
}

View File

@@ -1,103 +0,0 @@
"use client"
import * as React from "react"
import useSWR from "swr"
import { DEFAULT_CONFIG, PRESETS } from "@/registry/config"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const RESET_DIALOG_KEY = "create:reset-dialog-open"
export const RESET_FORWARD_TYPE = "reset-forward"
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(() => {
const preset =
PRESETS.find(
(preset) => preset.base === params.base && preset.style === params.style
) ?? DEFAULT_CONFIG
setParams({
base: params.base,
style: params.style,
baseColor: preset.baseColor,
theme: preset.theme,
chartColor: preset.chartColor,
iconLibrary: preset.iconLibrary,
font: preset.font,
fontHeading: preset.fontHeading,
menuAccent: preset.menuAccent,
menuColor: preset.menuColor,
radius: preset.radius,
template: DEFAULT_CONFIG.template,
item: params.item,
})
}, [setParams, params.base, params.style, params.item])
const handleShowResetDialogChange = React.useCallback(
(open: boolean) => {
void setShowResetDialogData(open, { revalidate: false })
},
[setShowResetDialogData]
)
const confirmReset = React.useCallback(() => {
reset()
void setShowResetDialogData(false, { revalidate: false })
}, [reset, setShowResetDialogData])
const showResetDialogRef = React.useRef(showResetDialog)
React.useEffect(() => {
showResetDialogRef.current = showResetDialog
}, [showResetDialog])
const confirmResetRef = React.useRef(confirmReset)
React.useEffect(() => {
confirmResetRef.current = confirmReset
}, [confirmReset])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "R" && e.shiftKey && !e.metaKey && !e.ctrlKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return
}
e.preventDefault()
// If the dialog is already open, confirm the reset.
if (showResetDialogRef.current) {
confirmResetRef.current()
return
}
handleShowResetDialogChange(true)
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [handleShowResetDialogChange])
return {
reset,
showResetDialog,
setShowResetDialog: handleShowResetDialogChange,
confirmReset,
}
}

View File

@@ -1,48 +0,0 @@
"use client"
import * as React from "react"
import { useTheme } from "next-themes"
import { useMetaColor } from "@/hooks/use-meta-color"
export function useThemeToggle() {
const { setTheme, resolvedTheme } = useTheme()
const { setMetaColor, metaColor } = useMetaColor()
React.useEffect(() => {
setMetaColor(metaColor)
}, [metaColor, setMetaColor])
const toggleTheme = React.useCallback(() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}, [resolvedTheme, setTheme])
// Listen for the D key to toggle theme.
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (
(e.key === "d" || e.key === "D") &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey
) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return
}
e.preventDefault()
toggleTheme()
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [toggleTheme])
return { toggleTheme }
}

View File

@@ -1,18 +0,0 @@
import { Suspense } from "react"
import { HistoryProvider } from "@/app/(app)/create/hooks/use-history"
import { LocksProvider } from "@/app/(app)/create/hooks/use-locks"
export default function CreateLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<LocksProvider>
<Suspense>
<HistoryProvider>{children}</HistoryProvider>
</Suspense>
</LocksProvider>
)
}

View File

@@ -1,72 +0,0 @@
import "server-only"
import { registryItemSchema } from "shadcn/schema"
import { BASES, getThemesForBaseColor, type BaseName } from "@/registry/config"
import {
ALLOWED_ITEM_TYPES,
EXCLUDED_ITEMS,
} from "@/app/(app)/create/lib/constants"
export async function getItemsForBase(base: BaseName) {
const { Index } = await import("@/registry/bases/__index__")
const index = Index[base]
if (!index) {
return []
}
return Object.values(index).filter(
(item) =>
ALLOWED_ITEM_TYPES.includes(item.type) &&
!EXCLUDED_ITEMS.includes(item.name)
)
}
export async function getBaseItem(name: string, base: BaseName) {
const { Index } = await import("@/registry/bases/__index__")
const index = Index[base]
if (!index?.[name] || EXCLUDED_ITEMS.includes(name)) {
return null
}
return registryItemSchema.parse(index[name])
}
export async function getBaseComponent(name: string, base: BaseName) {
const { Index } = await import("@/registry/bases/__index__")
const index = Index[base]
if (!index?.[name] || EXCLUDED_ITEMS.includes(name)) {
return null
}
return index[name].component
}
export async function getAllItems() {
const entries = await Promise.all(
BASES.map(async (base) => {
const items = await getItemsForBase(base.name as BaseName)
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)
}
// Re-export for server-side use.
export { getThemesForBaseColor }

View File

@@ -1,3 +0,0 @@
export const ALLOWED_ITEM_TYPES = ["registry:block", "registry:example"]
export const EXCLUDED_ITEMS = ["component-example"]

View File

@@ -1,233 +0,0 @@
import {
DM_Sans,
Figtree,
Geist,
Geist_Mono,
IBM_Plex_Sans,
Instrument_Sans,
Inter,
JetBrains_Mono,
Lora,
Manrope,
Merriweather,
Montserrat,
Noto_Sans,
Noto_Serif,
Nunito_Sans,
Outfit,
Oxanium,
Playfair_Display,
Public_Sans,
Raleway,
Roboto,
Roboto_Slab,
Source_Sans_3,
Space_Grotesk,
} from "next/font/google"
import { FONT_DEFINITIONS, type FontName } from "@/lib/font-definitions"
type PreviewFont = ReturnType<typeof Inter>
const geistSans = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
})
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
})
const notoSans = Noto_Sans({
subsets: ["latin"],
variable: "--font-noto-sans",
})
const nunitoSans = Nunito_Sans({
subsets: ["latin"],
variable: "--font-nunito-sans",
})
const figtree = Figtree({
subsets: ["latin"],
variable: "--font-figtree",
})
const roboto = Roboto({
subsets: ["latin"],
variable: "--font-roboto",
})
const raleway = Raleway({
subsets: ["latin"],
variable: "--font-raleway",
})
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-dm-sans",
})
const publicSans = Public_Sans({
subsets: ["latin"],
variable: "--font-public-sans",
})
const outfit = Outfit({
subsets: ["latin"],
variable: "--font-outfit",
})
const oxanium = Oxanium({
subsets: ["latin"],
variable: "--font-oxanium",
})
const manrope = Manrope({
subsets: ["latin"],
variable: "--font-manrope",
})
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-space-grotesk",
})
const montserrat = Montserrat({
subsets: ["latin"],
variable: "--font-montserrat",
})
const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"],
variable: "--font-ibm-plex-sans",
})
const sourceSans3 = Source_Sans_3({
subsets: ["latin"],
variable: "--font-source-sans-3",
})
const instrumentSans = Instrument_Sans({
subsets: ["latin"],
variable: "--font-instrument-sans",
})
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
})
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
})
const notoSerif = Noto_Serif({
subsets: ["latin"],
variable: "--font-noto-serif",
})
const robotoSlab = Roboto_Slab({
subsets: ["latin"],
variable: "--font-roboto-slab",
})
const merriweather = Merriweather({
subsets: ["latin"],
variable: "--font-merriweather",
})
const lora = Lora({
subsets: ["latin"],
variable: "--font-lora",
})
const playfairDisplay = Playfair_Display({
subsets: ["latin"],
variable: "--font-playfair-display",
})
const PREVIEW_FONTS = {
geist: geistSans,
inter,
"noto-sans": notoSans,
"nunito-sans": nunitoSans,
figtree,
roboto,
raleway,
"dm-sans": dmSans,
"public-sans": publicSans,
outfit,
oxanium,
manrope,
"space-grotesk": spaceGrotesk,
montserrat,
"ibm-plex-sans": ibmPlexSans,
"source-sans-3": sourceSans3,
"instrument-sans": instrumentSans,
"jetbrains-mono": jetbrainsMono,
"geist-mono": geistMono,
"noto-serif": notoSerif,
"roboto-slab": robotoSlab,
merriweather,
lora,
"playfair-display": playfairDisplay,
} satisfies Record<FontName, PreviewFont>
function createFontOption(name: FontName) {
const definition = FONT_DEFINITIONS.find((font) => font.name === name)
if (!definition) {
throw new Error(`Unknown font definition: ${name}`)
}
return {
name: definition.title,
value: definition.name,
font: PREVIEW_FONTS[name],
type: definition.type,
} as const
}
export const FONTS = [
createFontOption("geist"),
createFontOption("inter"),
createFontOption("noto-sans"),
createFontOption("nunito-sans"),
createFontOption("figtree"),
createFontOption("roboto"),
createFontOption("raleway"),
createFontOption("dm-sans"),
createFontOption("public-sans"),
createFontOption("outfit"),
createFontOption("oxanium"),
createFontOption("manrope"),
createFontOption("space-grotesk"),
createFontOption("montserrat"),
createFontOption("ibm-plex-sans"),
createFontOption("source-sans-3"),
createFontOption("instrument-sans"),
createFontOption("geist-mono"),
createFontOption("jetbrains-mono"),
createFontOption("noto-serif"),
createFontOption("roboto-slab"),
createFontOption("merriweather"),
createFontOption("lora"),
createFontOption("playfair-display"),
] as const
export type Font = (typeof FONTS)[number]
export const FONT_HEADING_OPTIONS = [
{
name: "Inherit",
value: "inherit",
font: null,
type: "default",
},
...FONTS,
] as const
export type FontHeadingOption = (typeof FONT_HEADING_OPTIONS)[number]

View File

@@ -1,32 +0,0 @@
import { registryItemSchema, type RegistryItem } from "shadcn/schema"
import { BASE_COLORS, THEMES } from "@/registry/config"
export function buildTheme(baseColorName: string, themeName: string) {
const baseColor = BASE_COLORS.find((c) => c.name === baseColorName)
const theme = THEMES.find((t) => t.name === themeName)
if (!baseColor || !theme) {
throw new Error(
`Base color "${baseColorName}" or theme "${themeName}" not found`
)
}
const mergedTheme: RegistryItem = {
name: `${baseColor.name}-${theme.name}`,
title: `${baseColor.title} ${theme.title}`,
type: "registry:theme",
cssVars: {
light: {
...baseColor.cssVars?.light,
...theme.cssVars?.light,
},
dark: {
...baseColor.cssVars?.dark,
...theme.cssVars?.dark,
},
},
}
return registryItemSchema.parse(mergedTheme)
}

View File

@@ -1,34 +0,0 @@
import { encodePreset, type PresetConfig } from "shadcn/preset"
import { type DesignSystemConfig } from "@/registry/config"
type PresetCodeConfig = Pick<
DesignSystemConfig,
| "style"
| "baseColor"
| "theme"
| "chartColor"
| "iconLibrary"
| "font"
| "fontHeading"
| "radius"
| "menuAccent"
| "menuColor"
>
export function getPresetCode(config: PresetCodeConfig) {
const presetConfig: Partial<PresetConfig> = {
style: config.style as PresetConfig["style"],
baseColor: config.baseColor as PresetConfig["baseColor"],
theme: config.theme as PresetConfig["theme"],
chartColor: config.chartColor as PresetConfig["chartColor"],
iconLibrary: config.iconLibrary as PresetConfig["iconLibrary"],
font: config.font as PresetConfig["font"],
fontHeading: config.fontHeading as PresetConfig["fontHeading"],
radius: config.radius as PresetConfig["radius"],
menuAccent: config.menuAccent as PresetConfig["menuAccent"],
menuColor: config.menuColor as PresetConfig["menuColor"],
}
return encodePreset(presetConfig)
}

View File

@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest"
import { resolvePresetOverrides } from "./preset-query"
describe("resolvePresetOverrides", () => {
it("prefers explicit fontHeading and chartColor query params", () => {
const overrides = resolvePresetOverrides(
new URLSearchParams("fontHeading=playfair-display&chartColor=emerald"),
{
theme: "neutral",
chartColor: "blue",
fontHeading: "inherit",
}
)
expect(overrides).toEqual({
fontHeading: "playfair-display",
chartColor: "emerald",
})
})
it("falls back to decoded preset values when no overrides are present", () => {
const overrides = resolvePresetOverrides(new URLSearchParams(), {
theme: "neutral",
chartColor: "blue",
fontHeading: "inherit",
})
expect(overrides).toEqual({
fontHeading: "inherit",
chartColor: "blue",
})
})
})

View File

@@ -1,30 +0,0 @@
import { V1_CHART_COLOR_MAP, type PresetConfig } from "shadcn/preset"
import { type ChartColorName, type FontHeadingValue } from "@/registry/config"
type SearchParamsLike = Pick<URLSearchParams, "get" | "has">
export function resolvePresetOverrides(
searchParams: SearchParamsLike,
decoded: Pick<PresetConfig, "theme" | "chartColor" | "fontHeading">
) {
const hasFontHeadingOverride = searchParams.has("fontHeading")
const hasChartColorOverride = searchParams.has("chartColor")
const fontHeading = hasFontHeadingOverride
? ((searchParams.get("fontHeading") ??
decoded.fontHeading) as FontHeadingValue)
: decoded.fontHeading
const chartColor = hasChartColorOverride
? ((searchParams.get("chartColor") ??
decoded.chartColor ??
V1_CHART_COLOR_MAP[decoded.theme] ??
decoded.theme) as ChartColorName)
: (decoded.chartColor ?? V1_CHART_COLOR_MAP[decoded.theme] ?? decoded.theme)
return {
fontHeading,
chartColor,
}
}

View File

@@ -1,108 +0,0 @@
import type {
BaseColor,
BaseColorName,
Radius,
StyleName,
Theme,
ThemeName,
} from "@/registry/config"
import { type FONTS } from "./fonts"
export type RandomizeContext = {
style?: StyleName
baseColor?: BaseColorName
theme?: ThemeName
chartColor?: string
iconLibrary?: string
font?: string
menuAccent?: string
menuColor?: string
radius?: string
}
export type BiasFilter<T> = (
items: readonly T[],
context: RandomizeContext
) => readonly T[]
export type RandomizeBiases = {
baseColors?: BiasFilter<BaseColor>
chartColors?: BiasFilter<Theme>
fonts?: BiasFilter<(typeof FONTS)[number]>
radius?: BiasFilter<Radius>
}
// Theme → chart color pairings for randomization.
const CHART_COLOR_PAIRINGS: Record<string, string[]> = {
red: ["teal", "sky"],
orange: ["teal", "blue"],
amber: ["cyan", "indigo"],
yellow: ["sky", "violet"],
lime: ["indigo", "pink"],
green: ["purple", "rose"],
emerald: ["purple", "red"],
teal: ["fuchsia", "red"],
cyan: ["rose", "amber"],
sky: ["red", "yellow"],
blue: ["orange", "yellow"],
indigo: ["amber", "yellow"],
violet: ["yellow", "lime"],
purple: ["green", "lime"],
fuchsia: ["lime", "teal"],
pink: ["green", "cyan"],
rose: ["emerald", "sky"],
}
/**
* Configuration for randomization biases.
* Add biases here to influence random selection based on context.
*/
export const RANDOMIZE_BIASES: RandomizeBiases = {
baseColors: (baseColors) => {
return baseColors.filter((c) => c.name !== "gray")
},
fonts: (fonts, context) => {
// When style is lyra, only use mono fonts.
if (context.style === "lyra") {
return fonts.filter((font) => font.value === "jetbrains-mono")
}
return fonts
},
radius: (radii, context) => {
// When style is lyra, always use "none" radius.
if (context.style === "lyra") {
return radii.filter((radius) => radius.name === "none")
}
return radii
},
chartColors: (chartColors, context) => {
// When theme has a pairing, restrict chart colors to the paired values.
const pairing = context.theme ? CHART_COLOR_PAIRINGS[context.theme] : null
if (pairing) {
const filtered = chartColors.filter((c) => pairing.includes(c.name))
if (filtered.length > 0) {
return filtered
}
}
return chartColors
},
}
/**
* Applies biases to a list of items based on the current context.
*/
export function applyBias<T>(
items: readonly T[],
context: RandomizeContext,
biasFilter?: BiasFilter<T>
): readonly T[] {
if (!biasFilter) {
return items
}
return biasFilter(items, context)
}

View File

@@ -1,309 +0,0 @@
import * as React from "react"
import { useSearchParams } from "next/navigation"
import { useQueryStates } from "nuqs"
import {
createLoader,
createSerializer,
parseAsBoolean,
parseAsInteger,
parseAsString,
parseAsStringLiteral,
type inferParserType,
type Options,
} from "nuqs/server"
import { decodePreset, isPresetCode } from "shadcn/preset"
import {
BASE_COLORS,
BASES,
DEFAULT_CONFIG,
getThemesForBaseColor,
iconLibraries,
MENU_ACCENTS,
MENU_COLORS,
RADII,
STYLES,
THEMES,
type BaseColorName,
type BaseName,
type ChartColorName,
type FontHeadingValue,
type FontValue,
type IconLibraryName,
type MenuAccentValue,
type MenuColorValue,
type RadiusValue,
type StyleName,
type ThemeName,
} from "@/registry/config"
import { FONTS } from "@/app/(app)/create/lib/fonts"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { resolvePresetOverrides } from "@/app/(app)/create/lib/preset-query"
const designSystemSearchParams = {
preset: parseAsString.withDefault("b2D0wqNxT"),
base: parseAsStringLiteral<BaseName>(BASES.map((b) => b.name)).withDefault(
DEFAULT_CONFIG.base
),
item: parseAsString.withDefault("preview-02").withOptions({ shallow: true }),
iconLibrary: parseAsStringLiteral<IconLibraryName>(
Object.values(iconLibraries).map((i) => i.name)
).withDefault(DEFAULT_CONFIG.iconLibrary),
style: parseAsStringLiteral<StyleName>(STYLES.map((s) => s.name)).withDefault(
DEFAULT_CONFIG.style
),
theme: parseAsStringLiteral<ThemeName>(THEMES.map((t) => t.name)).withDefault(
DEFAULT_CONFIG.theme
),
chartColor: parseAsStringLiteral<ChartColorName>(
THEMES.map((t) => t.name)
).withDefault(DEFAULT_CONFIG.chartColor ?? "neutral"),
font: parseAsStringLiteral<FontValue>(FONTS.map((f) => f.value)).withDefault(
DEFAULT_CONFIG.font
),
fontHeading: parseAsStringLiteral<FontHeadingValue>([
"inherit",
...FONTS.map((f) => f.value),
]).withDefault(DEFAULT_CONFIG.fontHeading),
baseColor: parseAsStringLiteral<BaseColorName>(
BASE_COLORS.map((b) => b.name)
).withDefault(DEFAULT_CONFIG.baseColor),
menuAccent: parseAsStringLiteral<MenuAccentValue>(
MENU_ACCENTS.map((a) => a.value)
).withDefault(DEFAULT_CONFIG.menuAccent),
menuColor: parseAsStringLiteral<MenuColorValue>(
MENU_COLORS.map((m) => m.value)
).withDefault(DEFAULT_CONFIG.menuColor),
radius: parseAsStringLiteral<RadiusValue>(
RADII.map((r) => r.name)
).withDefault("default"),
template: parseAsStringLiteral([
"next",
"next-monorepo",
"start",
"start-monorepo",
"react-router",
"react-router-monorepo",
"vite",
"vite-monorepo",
"astro",
"astro-monorepo",
"laravel",
] as const).withDefault("next"),
rtl: parseAsBoolean.withDefault(false),
size: parseAsInteger.withDefault(100),
custom: parseAsBoolean.withDefault(false),
}
// Design system param keys that get encoded into the preset code.
const DESIGN_SYSTEM_KEYS = [
"style",
"baseColor",
"theme",
"chartColor",
"iconLibrary",
"font",
"fontHeading",
"radius",
"menuAccent",
"menuColor",
] as const
function normalizeFontHeading(
font: FontValue,
fontHeading: FontHeadingValue
): FontHeadingValue {
// Persist "same as body" as an explicit inherit sentinel so the body font
// can change later without freezing headings to a concrete previous value.
return fontHeading === font ? "inherit" : fontHeading
}
// Non-design-system keys that get passed through as-is.
// `base` is not encoded in preset codes — it's an architectural choice, not visual.
const NON_DESIGN_SYSTEM_KEYS = [
"base",
"item",
"preset",
"template",
"rtl",
"size",
"custom",
] as const
export const loadDesignSystemSearchParams = createLoader(
designSystemSearchParams
)
export const serializeDesignSystemSearchParams = createSerializer(
designSystemSearchParams
)
export type DesignSystemSearchParams = inferParserType<
typeof designSystemSearchParams
>
export function isTranslucentMenuColor(
menuColor?: MenuColorValue | null
): menuColor is "default-translucent" | "inverted-translucent" {
return (
menuColor === "default-translucent" || menuColor === "inverted-translucent"
)
}
function normalizePartialDesignSystemParams(
params: Partial<DesignSystemSearchParams>
): Partial<DesignSystemSearchParams> {
if (
params.menuAccent === "bold" &&
isTranslucentMenuColor(params.menuColor ?? undefined)
) {
return {
...params,
menuAccent: "subtle",
}
}
return params
}
function normalizeDesignSystemParams(
params: DesignSystemSearchParams
): DesignSystemSearchParams {
let result = {
...params,
fontHeading: normalizeFontHeading(params.font, params.fontHeading),
}
// Validate theme and chartColor against baseColor.
if (result.baseColor) {
const available = getThemesForBaseColor(result.baseColor)
const themeValid = available.some((t) => t.name === result.theme)
const chartColorValid = available.some((t) => t.name === result.chartColor)
if (!themeValid || !chartColorValid) {
const fallback = (available[0]?.name ?? result.baseColor) as ThemeName
result = {
...result,
...(!themeValid && { theme: fallback }),
...(!chartColorValid && { chartColor: fallback as ChartColorName }),
}
}
}
if (
result.menuAccent === "bold" &&
isTranslucentMenuColor(result.menuColor)
) {
return {
...result,
menuAccent: "subtle",
}
}
return result
}
// If preset param exists, decode it and overlay on raw params.
// V1 presets don't encode chartColor — fall back to the colored
// theme that base-color themes originally borrowed charts from.
type SearchParamsLike = Pick<URLSearchParams, "get" | "has">
function resolvePresetParams(
rawParams: DesignSystemSearchParams,
searchParams: SearchParamsLike
) {
if (rawParams.preset && isPresetCode(rawParams.preset)) {
const decoded = decodePreset(rawParams.preset)
if (decoded) {
const presetOverrides = resolvePresetOverrides(searchParams, decoded)
return normalizeDesignSystemParams({
...decoded,
...presetOverrides,
base: rawParams.base,
item: rawParams.item,
preset: rawParams.preset,
template: rawParams.template,
rtl: rawParams.rtl,
size: rawParams.size,
custom: rawParams.custom,
})
}
}
return normalizeDesignSystemParams(rawParams)
}
// Wraps nuqs useQueryStates with transparent preset encoding/decoding.
// - Reads: if ?preset=CODE is in the URL, decodes it and returns individual values.
// - Writes: when design system params are set, encodes them into a preset code.
export function useDesignSystemSearchParams(options: Options = {}) {
const searchParams = useSearchParams()
const [rawParams, rawSetParams] = useQueryStates(designSystemSearchParams, {
shallow: false,
history: "push",
...options,
})
const params = React.useMemo(
() => resolvePresetParams(rawParams, searchParams),
[rawParams, searchParams]
)
// Use ref so setParams callback stays stable across renders.
const paramsRef = React.useRef(params)
React.useEffect(() => {
paramsRef.current = params
}, [params])
type RawSetParamsInput = Parameters<typeof rawSetParams>[0]
const setParams = React.useCallback(
(
updates:
| Partial<DesignSystemSearchParams>
| ((
old: DesignSystemSearchParams
) => Partial<DesignSystemSearchParams>),
setOptions?: Options
) => {
const resolvedUpdates = normalizePartialDesignSystemParams(
typeof updates === "function" ? updates(paramsRef.current) : updates
)
const hasDesignSystemUpdate = DESIGN_SYSTEM_KEYS.some(
(key) => key in resolvedUpdates
)
if (!hasDesignSystemUpdate) {
// No design system change, pass through directly.
return rawSetParams(resolvedUpdates as RawSetParamsInput, setOptions)
}
// Merge current decoded values with updates.
const merged = normalizeDesignSystemParams({
...paramsRef.current,
...resolvedUpdates,
})
// Encode design system fields into a preset code.
// Cast needed: merged values may include null from nuqs resets,
// but encodePreset handles missing values by falling back to defaults.
const code = getPresetCode(merged)
// Build update: set preset, clear individual DS params from URL.
const rawUpdate: Record<string, unknown> = { preset: code }
for (const key of DESIGN_SYSTEM_KEYS) {
rawUpdate[key] = null
}
// Pass through non-DS params that were explicitly in the update.
for (const key of NON_DESIGN_SYSTEM_KEYS) {
if (key in resolvedUpdates) {
rawUpdate[key] = (resolvedUpdates as Record<string, unknown>)[key]
}
}
return rawSetParams(rawUpdate as RawSetParamsInput, setOptions)
},
[rawSetParams]
)
return [params, setParams] as const
}

View File

@@ -1,55 +0,0 @@
export 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" fill="currentColor"/></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(--color-neutral-800)" 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>',
},
{
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" fill="currentColor"/></svg>',
},
{
value: "laravel",
title: "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" fill="currentColor"/></svg>',
},
{
value: "react-router",
title: "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" fill="currentColor"/></svg>',
},
{
value: "astro",
title: "Astro",
logo: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Astro</title><path d="M16.074 16.86C15.354 17.476 13.917 17.895 12.262 17.895C10.23 17.895 8.527 17.263 8.075 16.412C7.914 16.9 7.877 17.458 7.877 17.814C7.877 17.814 7.771 19.564 8.988 20.782C8.988 20.15 9.501 19.637 10.133 19.637C11.216 19.637 11.215 20.582 11.214 21.349V21.418C11.214 22.582 11.925 23.579 12.937 24C12.7812 23.6794 12.7005 23.3275 12.701 22.971C12.701 21.861 13.353 21.448 14.111 20.968C14.713 20.585 15.383 20.161 15.844 19.308C16.0926 18.8493 16.2225 18.3357 16.222 17.814C16.2221 17.4903 16.1722 17.1685 16.074 16.86ZM15.551 0.6C15.747 0.844 15.847 1.172 16.047 1.829L20.415 16.176C18.7743 15.3246 17.0134 14.7284 15.193 14.408L12.35 4.8C12.3273 4.72337 12.2803 4.65616 12.2162 4.60844C12.152 4.56072 12.0742 4.53505 11.9943 4.53528C11.9143 4.5355 11.8366 4.56161 11.7727 4.60969C11.7089 4.65777 11.6623 4.72524 11.64 4.802L8.83 14.405C7.00149 14.724 5.23264 15.3213 3.585 16.176L7.974 1.827C8.174 1.171 8.274 0.843 8.471 0.6C8.64406 0.385433 8.86922 0.218799 9.125 0.116C9.415 0 9.757 0 10.443 0H13.578C14.264 0 14.608 0 14.898 0.117C15.1529 0.219851 15.3783 0.386105 15.551 0.6Z" fill="currentColor"/></svg>',
},
] as const
export type TemplateValue = (typeof TEMPLATES)[number]["value"]
// Extract the base framework from a template value (e.g. "next-monorepo" -> "next").
export function getFramework(template: string) {
return template.replace("-monorepo", "") as TemplateValue
}
// 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
}

View File

@@ -1,44 +0,0 @@
import { type RegistryItem } from "shadcn/schema"
const mapping = {
"registry:block": "Blocks",
"registry:example": "Components",
}
export function groupItemsByType(
items: Pick<RegistryItem, "name" | "title" | "type">[]
) {
const grouped = items.reduce(
(acc, item) => {
acc[item.type] = [...(acc[item.type] || []), item]
return acc
},
{} as Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
)
return Object.entries(grouped)
.map(([type, items]) => ({
type,
title: mapping[type as keyof typeof mapping] || type,
items,
}))
.sort((a, b) => {
const aIndex = Object.keys(mapping).indexOf(a.type)
const bIndex = Object.keys(mapping).indexOf(b.type)
// If both are in mapping, sort by their order.
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex
}
// If only a is in mapping, it comes first.
if (aIndex !== -1) {
return -1
}
// If only b is in mapping, it comes first.
if (bIndex !== -1) {
return 1
}
// If neither is in mapping, maintain original order.
return 0
})
}

View File

@@ -1,115 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_CONFIG } from "@/registry/config"
import { buildV0Payload } from "@/app/(app)/create/lib/v0"
vi.mock("shadcn/schema", async () => {
return await vi.importActual("shadcn/schema")
})
vi.mock("shadcn/utils", async () => {
const actual = (await vi.importActual("shadcn/utils")) as {
transformFont: unknown
}
return {
transformFont: actual.transformFont,
transformIcons: async ({ sourceFile }: { sourceFile: unknown }) =>
sourceFile,
transformMenu: async ({ sourceFile }: { sourceFile: unknown }) =>
sourceFile,
transformRender: async ({ sourceFile }: { sourceFile: unknown }) =>
sourceFile,
}
})
vi.mock("@/registry/bases/__index__", () => ({
Index: {
base: {
card: {
name: "card",
type: "registry:ui",
},
},
radix: {
card: {
name: "card",
type: "registry:ui",
},
},
},
}))
describe("buildV0Payload", () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_APP_URL = "http://example.test"
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url
const name = url.split("/").pop()?.replace(".json", "") ?? "component"
return new Response(
JSON.stringify({
name,
type: "registry:ui",
files: [
{
path: `registry/base-nova/ui/${name}.tsx`,
type: "registry:ui",
content: `import * as React from "react"\n\nexport function Component() {\n return <div className="cn-font-heading text-xl" />\n}\n`,
},
],
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
}
)
})
)
})
afterEach(() => {
vi.unstubAllGlobals()
delete process.env.NEXT_PUBLIC_APP_URL
})
it("rewrites cn-font-heading to font-heading when heading inherits the body font", async () => {
const payload = await buildV0Payload({
...DEFAULT_CONFIG,
item: undefined,
fontHeading: "inherit",
})
const cardFile = payload.files?.find(
(file) => file.target === "components/ui/card.tsx"
)
expect(cardFile?.content).toContain("font-heading")
expect(cardFile?.content).not.toContain("cn-font-heading")
})
it("rewrites cn-font-heading to font-heading when a distinct heading font is selected", async () => {
const payload = await buildV0Payload({
...DEFAULT_CONFIG,
item: undefined,
fontHeading: "playfair-display",
})
const cardFile = payload.files?.find(
(file) => file.target === "components/ui/card.tsx"
)
expect(cardFile?.content).toContain("font-heading")
expect(cardFile?.content).not.toContain("cn-font-heading")
})
})

Some files were not shown because too many files have changed in this diff Show More