mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-12 18:31:46 +00:00
Compare commits
1 Commits
shadcn/pre
...
shadcn/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86479e0a30 |
@@ -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).
|
||||
78
.github/workflows/deprecated.yml
vendored
Normal file
78
.github/workflows/deprecated.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Deprecated
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
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:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v46
|
||||
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/') &&
|
||||
file !== 'apps/www/package.json'
|
||||
);
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
40
.github/workflows/validate-registries.yml
vendored
40
.github/workflows/validate-registries.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
check-registry-sync:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
name: check-registry-sync
|
||||
name: Check registry sync
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -66,44 +66,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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,7 +15,6 @@ build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
@@ -44,4 +43,3 @@ tsconfig.tsbuildinfo
|
||||
.notes
|
||||
.playwright-mcp
|
||||
shadcn-workspace
|
||||
.codex-artifacts
|
||||
|
||||
@@ -5,4 +5,3 @@ build
|
||||
.contentlayer
|
||||
**/fixtures
|
||||
deprecated
|
||||
apps/v4/registry/styles/**/*.css
|
||||
|
||||
@@ -5,4 +5,3 @@ build
|
||||
.contentlayer
|
||||
registry/__index__.tsx
|
||||
content/docs/components/calendar.mdx
|
||||
registry/styles/**/*.css
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconMinus, IconPlus } from "@tabler/icons-react"
|
||||
|
||||
import { Button } from "@/styles/radix-nova/ui/button"
|
||||
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
|
||||
import { Button } from "@/examples/radix/ui/button"
|
||||
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
@@ -15,10 +13,11 @@ 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 "@/examples/radix/ui/field"
|
||||
import { Input } from "@/examples/radix/ui/input"
|
||||
import { RadioGroup, RadioGroupItem } from "@/examples/radix/ui/radio-group"
|
||||
import { Switch } from "@/examples/radix/ui/switch"
|
||||
import { IconMinus, IconPlus } from "@tabler/icons-react"
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const [gpuCount, setGpuCount] = React.useState(8)
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArrowLeftIcon,
|
||||
CalendarPlusIcon,
|
||||
ClockIcon,
|
||||
ListFilterIcon,
|
||||
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 "@/examples/radix/ui/button"
|
||||
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -27,7 +15,18 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/styles/radix-nova/ui/dropdown-menu"
|
||||
} from "@/examples/radix/ui/dropdown-menu"
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArrowLeftIcon,
|
||||
CalendarPlusIcon,
|
||||
ClockIcon,
|
||||
ListFilterIcon,
|
||||
MailCheckIcon,
|
||||
MoreHorizontalIcon,
|
||||
TagIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
export function ButtonGroupDemo() {
|
||||
const [label, setLabel] = React.useState("personal")
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
"use client"
|
||||
|
||||
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 "@/examples/radix/ui/button"
|
||||
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/styles/radix-nova/ui/input-group"
|
||||
} from "@/examples/radix/ui/input-group"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/styles/radix-nova/ui/tooltip"
|
||||
} from "@/examples/radix/ui/tooltip"
|
||||
import { AudioLinesIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
export function ButtonGroupInputGroup() {
|
||||
const [voiceEnabled, setVoiceEnabled] = React.useState(false)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/examples/radix/ui/button"
|
||||
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
|
||||
|
||||
import { Button } from "@/styles/radix-nova/ui/button"
|
||||
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
|
||||
|
||||
export function ButtonGroupNested() {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 "@/examples/radix/ui/button"
|
||||
import { ButtonGroup } from "@/examples/radix/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 "@/examples/radix/ui/popover"
|
||||
import { Separator } from "@/examples/radix/ui/separator"
|
||||
import { Textarea } from "@/examples/radix/ui/textarea"
|
||||
import { BotIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
export function ButtonGroupPopover() {
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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 "@/examples/radix/ui/avatar"
|
||||
import { Button } from "@/examples/radix/ui/button"
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -14,7 +12,8 @@ import {
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/styles/radix-nova/ui/empty"
|
||||
} from "@/examples/radix/ui/empty"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
|
||||
export function EmptyAvatarGroup() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
|
||||
import { Field, FieldLabel } from "@/styles/radix-nova/ui/field"
|
||||
import { Checkbox } from "@/examples/radix/ui/checkbox"
|
||||
import { Field, FieldLabel } from "@/examples/radix/ui/field"
|
||||
|
||||
export function FieldCheckbox() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from "@/styles/radix-nova/ui/button"
|
||||
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
|
||||
import { Button } from "@/examples/radix/ui/button"
|
||||
import { Checkbox } from "@/examples/radix/ui/checkbox"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
} from "@/styles/radix-nova/ui/field"
|
||||
import { Input } from "@/styles/radix-nova/ui/input"
|
||||
} from "@/examples/radix/ui/field"
|
||||
import { Input } from "@/examples/radix/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/styles/radix-nova/ui/select"
|
||||
import { Textarea } from "@/styles/radix-nova/ui/textarea"
|
||||
} from "@/examples/radix/ui/select"
|
||||
import { Textarea } from "@/examples/radix/ui/textarea"
|
||||
|
||||
export function FieldDemo() {
|
||||
return (
|
||||
|
||||
@@ -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 "@/examples/radix/ui/card"
|
||||
import { Checkbox } from "@/examples/radix/ui/checkbox"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
FieldTitle,
|
||||
} from "@/styles/radix-nova/ui/field"
|
||||
} from "@/examples/radix/ui/field"
|
||||
|
||||
const options = [
|
||||
{
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldTitle,
|
||||
} from "@/styles/radix-nova/ui/field"
|
||||
import { Slider } from "@/styles/radix-nova/ui/slider"
|
||||
import { Field, FieldDescription, FieldTitle } from "@/examples/radix/ui/field"
|
||||
import { Slider } from "@/examples/radix/ui/slider"
|
||||
|
||||
export function FieldSlider() {
|
||||
const [value, setValue] = useState([200, 800])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FieldSeparator } from "@/styles/radix-nova/ui/field"
|
||||
import { FieldSeparator } from "@/examples/radix/ui/field"
|
||||
|
||||
import { AppearanceSettings } from "./appearance-settings"
|
||||
import { ButtonGroupDemo } from "./button-group-demo"
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
|
||||
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/styles/radix-nova/ui/input-group"
|
||||
import { Label } from "@/styles/radix-nova/ui/label"
|
||||
} from "@/examples/radix/ui/input-group"
|
||||
import { Label } from "@/examples/radix/ui/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/styles/radix-nova/ui/popover"
|
||||
} from "@/examples/radix/ui/popover"
|
||||
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
|
||||
|
||||
export function InputGroupButtonExample() {
|
||||
const [isFavorite, setIsFavorite] = React.useState(false)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { IconCheck, IconInfoCircle, IconPlus } from "@tabler/icons-react"
|
||||
import { ArrowUpIcon, Search } from "lucide-react"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/styles/radix-nova/ui/dropdown-menu"
|
||||
} from "@/examples/radix/ui/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
@@ -14,13 +11,15 @@ import {
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
} from "@/styles/radix-nova/ui/input-group"
|
||||
import { Separator } from "@/styles/radix-nova/ui/separator"
|
||||
} from "@/examples/radix/ui/input-group"
|
||||
import { Separator } from "@/examples/radix/ui/separator"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/styles/radix-nova/ui/tooltip"
|
||||
} from "@/examples/radix/ui/tooltip"
|
||||
import { IconCheck, IconInfoCircle, IconPlus } from "@tabler/icons-react"
|
||||
import { ArrowUpIcon, Search } from "lucide-react"
|
||||
|
||||
export function InputGroupDemo() {
|
||||
return (
|
||||
@@ -89,7 +88,7 @@ export function InputGroupDemo() {
|
||||
<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" />
|
||||
<IconCheck className="size-3 text-white" />
|
||||
</div>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
import { Button } from "@/styles/radix-nova/ui/button"
|
||||
import { Button } from "@/examples/radix/ui/button"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
@@ -8,7 +6,8 @@ import {
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/styles/radix-nova/ui/item"
|
||||
} from "@/examples/radix/ui/item"
|
||||
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
export function ItemDemo() {
|
||||
return (
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
IconApps,
|
||||
IconArrowUp,
|
||||
IconAt,
|
||||
IconBook,
|
||||
IconCircleDashedPlus,
|
||||
IconPaperclip,
|
||||
IconPlus,
|
||||
IconWorld,
|
||||
IconX,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/styles/radix-nova/ui/avatar"
|
||||
import { Badge } from "@/styles/radix-nova/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/examples/radix/ui/avatar"
|
||||
import { Badge } from "@/examples/radix/ui/badge"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -26,7 +10,7 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/styles/radix-nova/ui/command"
|
||||
} from "@/examples/radix/ui/command"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -39,25 +23,36 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/styles/radix-nova/ui/dropdown-menu"
|
||||
import { Field, FieldLabel } from "@/styles/radix-nova/ui/field"
|
||||
} from "@/examples/radix/ui/dropdown-menu"
|
||||
import { Field, FieldLabel } from "@/examples/radix/ui/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from "@/styles/radix-nova/ui/input-group"
|
||||
} from "@/examples/radix/ui/input-group"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/styles/radix-nova/ui/popover"
|
||||
import { Switch } from "@/styles/radix-nova/ui/switch"
|
||||
} from "@/examples/radix/ui/popover"
|
||||
import { Switch } from "@/examples/radix/ui/switch"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/styles/radix-nova/ui/tooltip"
|
||||
} from "@/examples/radix/ui/tooltip"
|
||||
import {
|
||||
IconApps,
|
||||
IconArrowUp,
|
||||
IconAt,
|
||||
IconBook,
|
||||
IconCircleDashedPlus,
|
||||
IconPaperclip,
|
||||
IconPlus,
|
||||
IconWorld,
|
||||
IconX,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
const SAMPLE_DATA = {
|
||||
mentionable: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge } from "@/styles/radix-nova/ui/badge"
|
||||
import { Spinner } from "@/styles/radix-nova/ui/spinner"
|
||||
import { Badge } from "@/examples/radix/ui/badge"
|
||||
import { Spinner } from "@/examples/radix/ui/spinner"
|
||||
|
||||
export function SpinnerBadge() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from "@/styles/radix-nova/ui/button"
|
||||
import { Button } from "@/examples/radix/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 "@/examples/radix/ui/empty"
|
||||
import { Spinner } from "@/examples/radix/ui/spinner"
|
||||
|
||||
export function SpinnerEmpty() {
|
||||
return (
|
||||
|
||||
@@ -63,7 +63,11 @@ export default function IndexPage() {
|
||||
</Button>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<div className="container-wrapper flex-1 pb-6">
|
||||
<PageNav className="hidden md:flex">
|
||||
<ExamplesNav className="flex-1 overflow-hidden [&>a:first-child]:text-primary" />
|
||||
<ThemeSelector className="mr-4 hidden md:flex" />
|
||||
</PageNav>
|
||||
<div className="container-wrapper flex-1 section-soft 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]">
|
||||
<Image
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { getThemesForBaseColor, STYLES } from "@/registry/config"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/styles/base-nova/ui/card"
|
||||
import { FieldGroup, FieldSeparator } from "@/styles/base-nova/ui/field"
|
||||
import { MenuAccentPicker } from "@/app/(app)/create/components/accent-picker"
|
||||
import { ActionMenu } from "@/app/(app)/create/components/action-menu"
|
||||
import { BaseColorPicker } from "@/app/(app)/create/components/base-color-picker"
|
||||
import { BasePicker } from "@/app/(app)/create/components/base-picker"
|
||||
import { ChartColorPicker } from "@/app/(app)/create/components/chart-color-picker"
|
||||
import { CopyPreset } from "@/app/(app)/create/components/copy-preset"
|
||||
import { FontPicker } from "@/app/(app)/create/components/font-picker"
|
||||
import { IconLibraryPicker } from "@/app/(app)/create/components/icon-library-picker"
|
||||
import { MainMenu } from "@/app/(app)/create/components/main-menu"
|
||||
import { MenuColorPicker } from "@/app/(app)/create/components/menu-picker"
|
||||
import { OpenPreset } from "@/app/(app)/create/components/open-preset"
|
||||
import { RadiusPicker } from "@/app/(app)/create/components/radius-picker"
|
||||
import { RandomButton } from "@/app/(app)/create/components/random-button"
|
||||
import { ResetDialog } from "@/app/(app)/create/components/reset-button"
|
||||
import { StylePicker } from "@/app/(app)/create/components/style-picker"
|
||||
import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
|
||||
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
|
||||
// Only visible when user clicks "Create Project".
|
||||
const ProjectForm = dynamic(() =>
|
||||
import("@/app/(app)/create/components/project-form").then(
|
||||
(m) => m.ProjectForm
|
||||
)
|
||||
)
|
||||
|
||||
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:rounded-b-none md:**:[button,a]:w-full">
|
||||
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
|
||||
<OpenPreset
|
||||
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
|
||||
label={isMobile ? "Open" : "Open Preset"}
|
||||
/>
|
||||
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
|
||||
<ActionMenu itemsByBase={itemsByBase} />
|
||||
<ResetDialog />
|
||||
</CardFooter>
|
||||
<CardFooter className="-mt-3 hidden min-w-0 gap-2 md:flex md:flex-col md:**:[button,a]:w-full">
|
||||
<ProjectForm />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Script from "next/script"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/styles/base-nova/ui/dialog"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-nova/ui/drawer"
|
||||
import { Field, FieldContent, FieldLabel } from "@/styles/base-nova/ui/field"
|
||||
import { Input } from "@/styles/base-nova/ui/input"
|
||||
import {
|
||||
OPEN_PRESET_FORWARD_TYPE,
|
||||
useOpenPreset,
|
||||
} from "@/app/(app)/create/hooks/use-open-preset"
|
||||
import { parsePresetInput } from "@/app/(app)/create/lib/parse-preset-input"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
|
||||
const PRESET_EXAMPLE = "b2D0wqNxT"
|
||||
const PRESET_TITLE = "Open Preset"
|
||||
const PRESET_DESCRIPTION = "Paste a preset code to load a saved configuration."
|
||||
|
||||
export function OpenPreset({
|
||||
className,
|
||||
label = "Open Preset",
|
||||
}: React.ComponentProps<typeof Button> & {
|
||||
label?: string
|
||||
}) {
|
||||
const [input, setInput] = React.useState("")
|
||||
const [, setParams] = useDesignSystemSearchParams()
|
||||
const isMobile = useIsMobile()
|
||||
const { open, setOpen } = useOpenPreset()
|
||||
|
||||
const nextPreset = React.useMemo(() => parsePresetInput(input), [input])
|
||||
const isInvalid = input.trim().length > 0 && nextPreset === null
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
|
||||
if (!nextOpen) {
|
||||
setInput("")
|
||||
}
|
||||
},
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!nextPreset) {
|
||||
return
|
||||
}
|
||||
|
||||
setParams({ preset: nextPreset })
|
||||
handleOpenChange(false)
|
||||
},
|
||||
[handleOpenChange, nextPreset, setParams]
|
||||
)
|
||||
|
||||
const triggerClassName = cn(
|
||||
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
||||
className
|
||||
)
|
||||
|
||||
const desktopTrigger = (
|
||||
<Button variant="outline" className={triggerClassName} />
|
||||
)
|
||||
|
||||
const fields = (
|
||||
<Field data-invalid={isInvalid || undefined}>
|
||||
<FieldLabel htmlFor="preset-code" className="sr-only">
|
||||
Preset code
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="preset-code"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder={`${PRESET_EXAMPLE} or --preset ${PRESET_EXAMPLE}`}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={isInvalid}
|
||||
className="h-10 md:h-8"
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline" className={triggerClassName}>
|
||||
{label}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="dark rounded-t-2xl!">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
|
||||
<DrawerDescription>{PRESET_DESCRIPTION}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-4 py-2">{fields}</div>
|
||||
<DrawerFooter>
|
||||
<Button type="submit" className="h-10" disabled={!nextPreset}>
|
||||
Open
|
||||
</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline" type="button" className="h-10">
|
||||
Cancel
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
|
||||
<DialogContent className="dark">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{PRESET_TITLE}</DialogTitle>
|
||||
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{fields}</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" type="button" />}>
|
||||
Cancel
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={!nextPreset}>
|
||||
Open
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpenPresetScript() {
|
||||
return (
|
||||
<Script
|
||||
id="open-preset-listener"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
// Forward O key.
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: '${OPEN_PRESET_FORWARD_TYPE}',
|
||||
key: e.key
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,166 +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 { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-open-preset"
|
||||
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
|
||||
import {
|
||||
serializeDesignSystemSearchParams,
|
||||
useDesignSystemSearchParams,
|
||||
} from "@/app/(app)/create/lib/search-params"
|
||||
|
||||
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
|
||||
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
|
||||
|
||||
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(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const iframeWindow = iframeRef.current?.contentWindow
|
||||
if (
|
||||
!iframeWindow ||
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== iframeWindow ||
|
||||
!event.data ||
|
||||
typeof event.data !== "object"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const type = event.data.type
|
||||
if (type === CMD_K_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "k",
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === RANDOMIZE_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "r",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === OPEN_PRESET_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "o",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === UNDO_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === REDO_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
shiftKey: true,
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === RESET_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "R",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === DARK_MODE_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "d",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const iframeSrc = React.useMemo(() => {
|
||||
// The iframe src needs to include the serialized design system params
|
||||
// for the initial load, but not be reactive to them as it would cause
|
||||
// full-iframe reloads on every param change (flashes & loss of state).
|
||||
// Further updates of the search params will be sent to the iframe
|
||||
// via a postMessage channel, for it to sync its own history onto the host's.
|
||||
return serializeDesignSystemSearchParams(
|
||||
`/preview/${params.base}/${params.item}`,
|
||||
params
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.base, params.item])
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col justify-center overflow-hidden rounded-2xl ring ring-foreground/10 md:ring-muted dark:ring-foreground/10">
|
||||
<div className="relative z-0 mx-auto flex w-full flex-1 flex-col overflow-hidden">
|
||||
<div className="absolute inset-0 bg-muted dark:bg-muted/30" />
|
||||
<iframe
|
||||
key={params.base + params.item}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
className="z-10 size-full flex-1"
|
||||
title="Preview"
|
||||
/>
|
||||
</div>
|
||||
<PreviewSwitcher />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
|
||||
const OPEN_PRESET_KEY = "create:open-preset-open"
|
||||
export const OPEN_PRESET_FORWARD_TYPE = "open-preset-forward"
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
return (
|
||||
(target instanceof HTMLElement && target.isContentEditable) ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
)
|
||||
}
|
||||
|
||||
export function useOpenPreset() {
|
||||
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
|
||||
OPEN_PRESET_KEY,
|
||||
{
|
||||
fallbackData: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
)
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
void setOpenData(nextOpen, { revalidate: false })
|
||||
},
|
||||
[setOpenData]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === "o" &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
if (isEditableTarget(e.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
void setOpenData(true, { revalidate: false })
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", down)
|
||||
}
|
||||
}, [setOpenData])
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen: handleOpenChange,
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenPresetTrigger() {
|
||||
const { mutate: setOpenData } = useSWR<boolean>(OPEN_PRESET_KEY, {
|
||||
fallbackData: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const openPreset = React.useCallback(() => {
|
||||
void setOpenData(true, { revalidate: false })
|
||||
}, [setOpenData])
|
||||
|
||||
return {
|
||||
openPreset,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { parsePresetInput } from "./parse-preset-input"
|
||||
|
||||
describe("parsePresetInput", () => {
|
||||
it("accepts a raw preset code", () => {
|
||||
expect(parsePresetInput("b0")).toBe("b0")
|
||||
})
|
||||
|
||||
it("accepts a --preset flag", () => {
|
||||
expect(parsePresetInput(" --preset b0 ")).toBe("b0")
|
||||
})
|
||||
|
||||
it("rejects invalid preset input", () => {
|
||||
expect(parsePresetInput("open sesame")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { isPresetCode } from "shadcn/preset"
|
||||
|
||||
const PRESET_FLAG_PATTERN = /^--preset\b\s+(.+)$/i
|
||||
|
||||
export function parsePresetInput(value: string) {
|
||||
const input = value.trim()
|
||||
|
||||
if (!input) {
|
||||
return null
|
||||
}
|
||||
|
||||
const preset = input.match(PRESET_FLAG_PATTERN)?.[1]?.trim() ?? input
|
||||
|
||||
return isPresetCode(preset) ? preset : null
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("b0"),
|
||||
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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Suspense } from "react"
|
||||
import { type Metadata } from "next"
|
||||
import dynamic from "next/dynamic"
|
||||
|
||||
import { siteConfig } from "@/lib/config"
|
||||
import { absoluteUrl } from "@/lib/utils"
|
||||
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
||||
import { Customizer } from "@/app/(app)/create/components/customizer"
|
||||
import { PresetHandler } from "@/app/(app)/create/components/preset-handler"
|
||||
import { Preview } from "@/app/(app)/create/components/preview"
|
||||
import { getAllItems } from "@/app/(app)/create/lib/api"
|
||||
|
||||
// Only shown on first visit (checks localStorage).
|
||||
const WelcomeDialog = dynamic(() =>
|
||||
import("@/app/(app)/create/components/welcome-dialog").then(
|
||||
(m) => m.WelcomeDialog
|
||||
)
|
||||
)
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "New Project",
|
||||
description:
|
||||
"Customize everything. Pick your component library, icons, base color, theme, fonts and create your own version of shadcn/ui.",
|
||||
openGraph: {
|
||||
title: "New Project",
|
||||
description:
|
||||
"Customize everything. Pick your component library, icons, base color, theme, fonts and create your own version of shadcn/ui.",
|
||||
type: "website",
|
||||
url: absoluteUrl("/create"),
|
||||
images: [
|
||||
{
|
||||
url: siteConfig.ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteConfig.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "New Project",
|
||||
description:
|
||||
"Customize everything. Pick your component library, icons, base color, theme, fonts and create your own version of shadcn/ui.",
|
||||
images: [siteConfig.ogImage],
|
||||
creator: "@shadcn",
|
||||
},
|
||||
}
|
||||
|
||||
export default function CreatePage() {
|
||||
return (
|
||||
<div className="relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden section-soft [--customizer-width:--spacing(48)] [--gap:--spacing(4)] md:[--gap:--spacing(6)] 2xl:[--customizer-width:--spacing(56)]">
|
||||
<div
|
||||
data-slot="designer"
|
||||
className="flex min-h-0 flex-1 flex-col gap-(--gap) p-(--gap) pt-[calc(var(--gap)*0.25)] md:flex-row-reverse"
|
||||
>
|
||||
<Preview />
|
||||
<Suspense
|
||||
fallback={
|
||||
<Skeleton className="isolate min-h-[151px] w-full self-start rounded-2xl md:h-full md:max-h-full md:min-h-0 md:w-(--customizer-width)" />
|
||||
}
|
||||
>
|
||||
<CustomizerLoader />
|
||||
</Suspense>
|
||||
</div>
|
||||
<PresetHandler />
|
||||
<WelcomeDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function CustomizerLoader() {
|
||||
const itemsByBase = await getAllItems()
|
||||
return <Customizer itemsByBase={itemsByBase} />
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { mdxComponents } from "@/mdx-components"
|
||||
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"
|
||||
import { findNeighbour } from "fumadocs-core/page-tree"
|
||||
|
||||
import { replaceComponentsList } from "@/lib/llm"
|
||||
import { source } from "@/lib/source"
|
||||
import { absoluteUrl } from "@/lib/utils"
|
||||
import { DocsBaseSwitcher } from "@/components/docs-base-switcher"
|
||||
@@ -84,7 +83,7 @@ export default async function Page(props: {
|
||||
const neighbours = isChangelog
|
||||
? { previous: null, next: null }
|
||||
: findNeighbour(source.pageTree, page.url)
|
||||
const raw = replaceComponentsList(await page.data.getText("raw"))
|
||||
const raw = await page.data.getText("raw")
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/examples/radix/ui/button"
|
||||
import { mdxComponents } from "@/mdx-components"
|
||||
import { IconRss } from "@tabler/icons-react"
|
||||
|
||||
import { getChangelogPages, type ChangelogPageData } from "@/lib/changelog"
|
||||
import { absoluteUrl } from "@/lib/utils"
|
||||
import { OpenInV0Cta } from "@/components/open-in-v0-cta"
|
||||
import { Button } from "@/styles/radix-nova/ui/button"
|
||||
|
||||
export const revalidate = false
|
||||
export const dynamic = "force-static"
|
||||
|
||||
const NUMBER_OF_LATEST_PAGES = 2
|
||||
|
||||
export function generateMetadata() {
|
||||
return {
|
||||
title: "Changelog",
|
||||
@@ -36,8 +34,8 @@ export function generateMetadata() {
|
||||
|
||||
export default function ChangelogPage() {
|
||||
const pages = getChangelogPages()
|
||||
const latestPages = pages.slice(0, NUMBER_OF_LATEST_PAGES)
|
||||
const olderPages = pages.slice(NUMBER_OF_LATEST_PAGES)
|
||||
const latestPages = pages.slice(0, 5)
|
||||
const olderPages = pages.slice(5)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -46,7 +44,7 @@ export default function ChangelogPage() {
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="h-(--top-spacing) shrink-0" />
|
||||
<div className="mx-auto flex w-full max-w-160 min-w-0 flex-1 flex-col gap-6 px-4 py-6 text-neutral-800 md:px-0 lg:py-8 dark:text-neutral-300">
|
||||
<div className="mx-auto flex w-full max-w-[40rem] min-w-0 flex-1 flex-col gap-6 px-4 py-6 text-neutral-800 md:px-0 lg:py-8 dark:text-neutral-300">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="scroll-m-24 text-4xl font-semibold tracking-tight sm:text-3xl">
|
||||
@@ -81,7 +79,7 @@ export default function ChangelogPage() {
|
||||
})}
|
||||
{olderPages.length > 0 && (
|
||||
<div id="more-updates" className="mb-24 scroll-mt-24">
|
||||
<h2 className="mb-6 font-heading text-xl font-semibold tracking-tight">
|
||||
<h2 className="font-heading mb-6 text-xl font-semibold tracking-tight">
|
||||
More Updates
|
||||
</h2>
|
||||
<div className="grid auto-rows-fr gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconMinus, IconPlus } from "@tabler/icons-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Button } from "@/styles/base-nova/ui-rtl/button"
|
||||
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
|
||||
import { Button } from "@/examples/base/ui-rtl/button"
|
||||
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
@@ -16,13 +13,13 @@ import {
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldTitle,
|
||||
} from "@/styles/base-nova/ui-rtl/field"
|
||||
import { Input } from "@/styles/base-nova/ui-rtl/input"
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@/styles/base-nova/ui-rtl/radio-group"
|
||||
import { Switch } from "@/styles/base-nova/ui-rtl/switch"
|
||||
} from "@/examples/base/ui-rtl/field"
|
||||
import { Input } from "@/examples/base/ui-rtl/input"
|
||||
import { RadioGroup, RadioGroupItem } from "@/examples/base/ui-rtl/radio-group"
|
||||
import { Switch } from "@/examples/base/ui-rtl/switch"
|
||||
import { IconMinus, IconPlus } from "@tabler/icons-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArrowLeftIcon,
|
||||
CalendarPlusIcon,
|
||||
ClockIcon,
|
||||
ListFilterIcon,
|
||||
MailCheckIcon,
|
||||
MoreHorizontalIcon,
|
||||
TagIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Button } from "@/styles/base-nova/ui-rtl/button"
|
||||
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
|
||||
import { Button } from "@/examples/base/ui-rtl/button"
|
||||
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -29,7 +16,20 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/styles/base-nova/ui-rtl/dropdown-menu"
|
||||
} from "@/examples/base/ui-rtl/dropdown-menu"
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArrowLeftIcon,
|
||||
CalendarPlusIcon,
|
||||
ClockIcon,
|
||||
ListFilterIcon,
|
||||
MailCheckIcon,
|
||||
MoreHorizontalIcon,
|
||||
TagIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AudioLinesIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Button } from "@/styles/base-nova/ui-rtl/button"
|
||||
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
|
||||
import { Button } from "@/examples/base/ui-rtl/button"
|
||||
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/styles/base-nova/ui-rtl/input-group"
|
||||
} from "@/examples/base/ui-rtl/input-group"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/styles/base-nova/ui-rtl/tooltip"
|
||||
} from "@/examples/base/ui-rtl/tooltip"
|
||||
import { AudioLinesIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/examples/base/ui-rtl/button"
|
||||
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Button } from "@/styles/base-nova/ui-rtl/button"
|
||||
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { BotIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Button } from "@/styles/base-nova/ui-rtl/button"
|
||||
import { ButtonGroup } from "@/styles/base-nova/ui-rtl/button-group"
|
||||
import { Button } from "@/examples/base/ui-rtl/button"
|
||||
import { ButtonGroup } from "@/examples/base/ui-rtl/button-group"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/styles/base-nova/ui-rtl/popover"
|
||||
import { Separator } from "@/styles/base-nova/ui-rtl/separator"
|
||||
import { Textarea } from "@/styles/base-nova/ui-rtl/textarea"
|
||||
} from "@/examples/base/ui-rtl/popover"
|
||||
import { Separator } from "@/examples/base/ui-rtl/separator"
|
||||
import { Textarea } from "@/examples/base/ui-rtl/textarea"
|
||||
import { BotIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { PlusIcon } from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarImage,
|
||||
} from "@/styles/base-nova/ui-rtl/avatar"
|
||||
import { Button } from "@/styles/base-nova/ui-rtl/button"
|
||||
} from "@/examples/base/ui-rtl/avatar"
|
||||
import { Button } from "@/examples/base/ui-rtl/button"
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -17,7 +14,10 @@ import {
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/styles/base-nova/ui-rtl/empty"
|
||||
} from "@/examples/base/ui-rtl/empty"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox } from "@/examples/base/ui-rtl/checkbox"
|
||||
import { Field, FieldLabel } from "@/examples/base/ui-rtl/field"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Checkbox } from "@/styles/base-nova/ui-rtl/checkbox"
|
||||
import { Field, FieldLabel } from "@/styles/base-nova/ui-rtl/field"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Button } from "@/styles/base-nova/ui-rtl/button"
|
||||
import { Checkbox } from "@/styles/base-nova/ui-rtl/checkbox"
|
||||
import { Button } from "@/examples/base/ui-rtl/button"
|
||||
import { Checkbox } from "@/examples/base/ui-rtl/checkbox"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
@@ -11,8 +10,8 @@ import {
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
} from "@/styles/base-nova/ui-rtl/field"
|
||||
import { Input } from "@/styles/base-nova/ui-rtl/input"
|
||||
} from "@/examples/base/ui-rtl/field"
|
||||
import { Input } from "@/examples/base/ui-rtl/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -20,8 +19,10 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/styles/base-nova/ui-rtl/select"
|
||||
import { Textarea } from "@/styles/base-nova/ui-rtl/textarea"
|
||||
} from "@/examples/base/ui-rtl/select"
|
||||
import { Textarea } from "@/examples/base/ui-rtl/textarea"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
@@ -139,7 +140,7 @@ export function FieldDemo() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rtl-exp-month">{t.month}</FieldLabel>
|
||||
<Select defaultValue="">
|
||||
<Select defaultValue="" items={months}>
|
||||
<SelectTrigger id="rtl-exp-month">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
@@ -156,7 +157,7 @@ export function FieldDemo() {
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rtl-exp-year">{t.year}</FieldLabel>
|
||||
<Select defaultValue="">
|
||||
<Select defaultValue="" items={years}>
|
||||
<SelectTrigger id="rtl-exp-year">
|
||||
<SelectValue placeholder="YYYY" />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Card, CardContent } from "@/styles/base-nova/ui-rtl/card"
|
||||
import { Checkbox } from "@/styles/base-nova/ui-rtl/checkbox"
|
||||
import { Card, CardContent } from "@/examples/base/ui-rtl/card"
|
||||
import { Checkbox } from "@/examples/base/ui-rtl/checkbox"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
@@ -11,7 +10,9 @@ import {
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
FieldTitle,
|
||||
} from "@/styles/base-nova/ui-rtl/field"
|
||||
} from "@/examples/base/ui-rtl/field"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldTitle,
|
||||
} from "@/styles/base-nova/ui-rtl/field"
|
||||
import { Slider } from "@/styles/base-nova/ui-rtl/slider"
|
||||
} from "@/examples/base/ui-rtl/field"
|
||||
import { Slider } from "@/examples/base/ui-rtl/slider"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { DirectionProvider } from "@/examples/base/ui-rtl/direction"
|
||||
import { FieldSeparator } from "@/examples/base/ui-rtl/field"
|
||||
|
||||
import {
|
||||
LanguageProvider,
|
||||
LanguageSelector,
|
||||
useLanguageContext,
|
||||
} from "@/components/language-selector"
|
||||
import { DirectionProvider } from "@/styles/base-nova/ui-rtl/direction"
|
||||
import { FieldSeparator } from "@/styles/base-nova/ui-rtl/field"
|
||||
|
||||
import { AppearanceSettings } from "./appearance-settings"
|
||||
import { ButtonGroupDemo } from "./button-group-demo"
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/styles/base-nova/ui-rtl/input-group"
|
||||
import { Label } from "@/styles/base-nova/ui-rtl/label"
|
||||
} from "@/examples/base/ui-rtl/input-group"
|
||||
import { Label } from "@/examples/base/ui-rtl/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/styles/base-nova/ui-rtl/popover"
|
||||
} from "@/examples/base/ui-rtl/popover"
|
||||
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/examples/base/ui-rtl/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
} from "@/examples/base/ui-rtl/input-group"
|
||||
import { Separator } from "@/examples/base/ui-rtl/separator"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/examples/base/ui-rtl/tooltip"
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
@@ -9,26 +29,6 @@ import {
|
||||
import { ArrowUpIcon, Search } from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/styles/base-nova/ui-rtl/dropdown-menu"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
} from "@/styles/base-nova/ui-rtl/input-group"
|
||||
import { Separator } from "@/styles/base-nova/ui-rtl/separator"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/styles/base-nova/ui-rtl/tooltip"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Button } from "@/styles/base-nova/ui-rtl/button"
|
||||
import { Button } from "@/examples/base/ui-rtl/button"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
@@ -11,7 +8,10 @@ import {
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/styles/base-nova/ui-rtl/item"
|
||||
} from "@/examples/base/ui-rtl/item"
|
||||
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,6 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/examples/base/ui-rtl/avatar"
|
||||
import { Badge } from "@/examples/base/ui-rtl/badge"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/examples/base/ui-rtl/command"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/examples/base/ui-rtl/dropdown-menu"
|
||||
import { Field, FieldLabel } from "@/examples/base/ui-rtl/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from "@/examples/base/ui-rtl/input-group"
|
||||
import { Popover, PopoverContent } from "@/examples/base/ui-rtl/popover"
|
||||
import { Switch } from "@/examples/base/ui-rtl/switch"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/examples/base/ui-rtl/tooltip"
|
||||
import {
|
||||
IconApps,
|
||||
IconArrowUp,
|
||||
@@ -14,47 +55,6 @@ import {
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/styles/base-nova/ui-rtl/avatar"
|
||||
import { Badge } from "@/styles/base-nova/ui-rtl/badge"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/styles/base-nova/ui-rtl/command"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/styles/base-nova/ui-rtl/dropdown-menu"
|
||||
import { Field, FieldLabel } from "@/styles/base-nova/ui-rtl/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from "@/styles/base-nova/ui-rtl/input-group"
|
||||
import { Popover, PopoverContent } from "@/styles/base-nova/ui-rtl/popover"
|
||||
import { Switch } from "@/styles/base-nova/ui-rtl/switch"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/styles/base-nova/ui-rtl/tooltip"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/examples/base/ui-rtl/badge"
|
||||
import { Spinner } from "@/examples/base/ui-rtl/spinner"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Badge } from "@/styles/base-nova/ui-rtl/badge"
|
||||
import { Spinner } from "@/styles/base-nova/ui-rtl/spinner"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
import { Button } from "@/styles/base-nova/ui-rtl/button"
|
||||
import { Button } from "@/examples/base/ui-rtl/button"
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -9,8 +8,10 @@ import {
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/styles/base-nova/ui-rtl/empty"
|
||||
import { Spinner } from "@/styles/base-nova/ui-rtl/spinner"
|
||||
} from "@/examples/base/ui-rtl/empty"
|
||||
import { Spinner } from "@/examples/base/ui-rtl/spinner"
|
||||
|
||||
import { useLanguageContext } from "@/components/language-selector"
|
||||
|
||||
const translations = {
|
||||
ar: {
|
||||
|
||||
@@ -5,10 +5,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="layout"
|
||||
className="group/layout relative z-10 flex min-h-svh flex-col bg-background has-data-[slot=designer]:h-svh has-data-[slot=designer]:overflow-hidden"
|
||||
className="relative z-10 flex min-h-svh flex-col bg-background"
|
||||
>
|
||||
<SiteHeader />
|
||||
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
|
||||
<main className="flex flex-1 flex-col">{children}</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
)
|
||||
|
||||
64
apps/v4/app/(app)/themes/layout.tsx
Normal file
64
apps/v4/app/(app)/themes/layout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { type Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Announcement } from "@/components/announcement"
|
||||
import {
|
||||
PageActions,
|
||||
PageHeader,
|
||||
PageHeaderDescription,
|
||||
PageHeaderHeading,
|
||||
} from "@/components/page-header"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
|
||||
const title = "Pick a Color. Make it yours."
|
||||
const description =
|
||||
"Try our hand-picked themes. Copy and paste them into your project. New theme editor coming soon."
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
images: [
|
||||
{
|
||||
url: `/og?title=${encodeURIComponent(
|
||||
title
|
||||
)}&description=${encodeURIComponent(description)}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
images: [
|
||||
{
|
||||
url: `/og?title=${encodeURIComponent(
|
||||
title
|
||||
)}&description=${encodeURIComponent(description)}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default function ThemesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<Announcement />
|
||||
<PageHeaderHeading>{title}</PageHeaderHeading>
|
||||
<PageHeaderDescription>{description}</PageHeaderDescription>
|
||||
<PageActions>
|
||||
<Button asChild size="sm">
|
||||
<a href="#themes">Browse Themes</a>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/docs/theming">Documentation</Link>
|
||||
</Button>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
apps/v4/app/(app)/themes/page.tsx
Normal file
22
apps/v4/app/(app)/themes/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { CardsDemo } from "@/components/cards"
|
||||
import { ThemeCustomizer } from "@/components/theme-customizer"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
export const revalidate = false
|
||||
|
||||
export default function ThemesPage() {
|
||||
return (
|
||||
<>
|
||||
<div id="themes" className="container-wrapper scroll-mt-20">
|
||||
<div className="container flex items-center justify-between gap-8 px-6 py-4 md:px-8">
|
||||
<ThemeCustomizer />
|
||||
</div>
|
||||
</div>
|
||||
<div className="container-wrapper flex flex-1 flex-col section-soft pb-6">
|
||||
<div className="container flex flex-1 flex-col theme-container">
|
||||
<CardsDemo />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { MENU_ACCENTS, type MenuAccentValue } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
||||
import { LockButton } from "@/app/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/create/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
} from "@/app/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
|
||||
export function MenuAccentPicker({
|
||||
isMobile,
|
||||
@@ -82,11 +82,6 @@ export function MenuAccentPicker({
|
||||
key={accent.value}
|
||||
value={accent.value}
|
||||
closeOnClick={isMobile}
|
||||
disabled={
|
||||
accent.value === "bold" &&
|
||||
(params.menuColor === "default-translucent" ||
|
||||
params.menuColor === "inverted-translucent")
|
||||
}
|
||||
>
|
||||
{accent.label}
|
||||
</PickerRadioItem>
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import Script from "next/script"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
@@ -11,8 +9,10 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/styles/base-nova/ui/command"
|
||||
import { useActionMenu } from "@/app/(app)/create/hooks/use-action-menu"
|
||||
} from "@/examples/base/ui/command"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
|
||||
import { useActionMenu } from "@/app/(create)/hooks/use-action-menu"
|
||||
|
||||
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { LockButton } from "@/app/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/create/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
} from "@/app/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
|
||||
export function BaseColorPicker({
|
||||
isMobile,
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/create/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
} from "@/app/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
|
||||
export function BasePicker({
|
||||
isMobile,
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Button } from "@/examples/base/ui/button"
|
||||
|
||||
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"
|
||||
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
|
||||
|
||||
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
||||
const presetCode = usePresetCode()
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
const label = hasCopied ? "Copied" : `--preset ${presetCode}`
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasCopied) {
|
||||
@@ -33,13 +32,12 @@ export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
title={label}
|
||||
className={cn(
|
||||
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="block min-w-0 truncate">{label}</span>
|
||||
<span>{hasCopied ? "Copied" : `--preset ${presetCode}`}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
88
apps/v4/app/(create)/components/customizer.tsx
Normal file
88
apps/v4/app/(create)/components/customizer.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/examples/base/ui/card"
|
||||
import { FieldGroup } from "@/examples/base/ui/field"
|
||||
import { Separator } from "@/examples/base/ui/separator"
|
||||
import { CardTitle } from "@/examples/radix/ui/card"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { getThemesForBaseColor, STYLES } from "@/registry/config"
|
||||
import { MenuAccentPicker } from "@/app/(create)/components/accent-picker"
|
||||
import { ActionMenu } from "@/app/(create)/components/action-menu"
|
||||
import { BaseColorPicker } from "@/app/(create)/components/base-color-picker"
|
||||
import { BasePicker } from "@/app/(create)/components/base-picker"
|
||||
import { CopyPreset } from "@/app/(create)/components/copy-preset"
|
||||
import { FontPicker } from "@/app/(create)/components/font-picker"
|
||||
import { IconLibraryPicker } from "@/app/(create)/components/icon-library-picker"
|
||||
import { MainMenu } from "@/app/(create)/components/main-menu"
|
||||
import { MenuColorPicker } from "@/app/(create)/components/menu-picker"
|
||||
import { ProjectForm } from "@/app/(create)/components/project-form"
|
||||
import { RadiusPicker } from "@/app/(create)/components/radius-picker"
|
||||
import { RandomButton } from "@/app/(create)/components/random-button"
|
||||
import { ResetDialog } from "@/app/(create)/components/reset-button"
|
||||
import { StylePicker } from "@/app/(create)/components/style-picker"
|
||||
import { ThemePicker } from "@/app/(create)/components/theme-picker"
|
||||
import { V0Button } from "@/app/(create)/components/v0-button"
|
||||
import { FONTS } from "@/app/(create)/lib/fonts"
|
||||
import { useDesignSystemSearchParams } from "@/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 md:flex-col md:gap-3.25">
|
||||
<BasePicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<StylePicker
|
||||
styles={STYLES}
|
||||
isMobile={isMobile}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
<BaseColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<ThemePicker
|
||||
themes={availableThemes}
|
||||
isMobile={isMobile}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
<IconLibraryPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<FontPicker fonts={FONTS} isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<RadiusPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<MenuColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<MenuAccentPicker 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>
|
||||
)
|
||||
}
|
||||
199
apps/v4/app/(create)/components/design-system-provider.tsx
Normal file
199
apps/v4/app/(create)/components/design-system-provider.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import {
|
||||
buildRegistryTheme,
|
||||
DEFAULT_CONFIG,
|
||||
type DesignSystemConfig,
|
||||
type RadiusValue,
|
||||
} from "@/registry/config"
|
||||
import { useIframeMessageListener } from "@/app/(create)/hooks/use-iframe-sync"
|
||||
import { FONTS } from "@/app/(create)/lib/fonts"
|
||||
import {
|
||||
useDesignSystemSearchParams,
|
||||
type DesignSystemSearchParams,
|
||||
} from "@/app/(create)/lib/search-params"
|
||||
|
||||
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 [liveSearchParams, setLiveSearchParams] = React.useState(searchParams)
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const { style, theme, font, baseColor, menuAccent, menuColor, radius } =
|
||||
liveSearchParams
|
||||
const effectiveRadius = style === "lyra" ? "none" : radius
|
||||
|
||||
React.useEffect(() => {
|
||||
setLiveSearchParams(searchParams)
|
||||
}, [searchParams])
|
||||
|
||||
const handleDesignSystemMessage = React.useCallback(
|
||||
(nextParams: DesignSystemSearchParams) => {
|
||||
setLiveSearchParams(nextParams)
|
||||
setSearchParams(nextParams)
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (style === "lyra" && radius !== "none") {
|
||||
setLiveSearchParams((prev) => ({
|
||||
...prev,
|
||||
radius: "none",
|
||||
}))
|
||||
setSearchParams({ radius: "none" as RadiusValue })
|
||||
}
|
||||
}, [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.
|
||||
Array.from(body.classList).forEach((className) => {
|
||||
if (
|
||||
className.startsWith("style-") ||
|
||||
className.startsWith("base-color-")
|
||||
) {
|
||||
body.classList.remove(className)
|
||||
}
|
||||
})
|
||||
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.
|
||||
const selectedFont = FONTS.find((f) => f.value === font)
|
||||
if (selectedFont) {
|
||||
const fontFamily = selectedFont.font.style.fontFamily
|
||||
document.documentElement.style.setProperty("--font-sans", fontFamily)
|
||||
}
|
||||
|
||||
setIsReady(true)
|
||||
}, [style, theme, font, baseColor])
|
||||
|
||||
const registryTheme = React.useMemo(() => {
|
||||
if (!baseColor || !theme || !menuAccent || !effectiveRadius) {
|
||||
return null
|
||||
}
|
||||
|
||||
const config: DesignSystemConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
baseColor,
|
||||
theme,
|
||||
menuAccent,
|
||||
radius: effectiveRadius,
|
||||
}
|
||||
|
||||
return buildRegistryTheme(config)
|
||||
}, [baseColor, theme, menuAccent, effectiveRadius])
|
||||
|
||||
// Use useLayoutEffect for synchronous CSS var updates.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!registryTheme || !registryTheme.cssVars) {
|
||||
return
|
||||
}
|
||||
|
||||
const styleId = "design-system-theme-vars"
|
||||
let styleElement = document.getElementById(
|
||||
styleId
|
||||
) as HTMLStyleElement | null
|
||||
|
||||
if (!styleElement) {
|
||||
styleElement = document.createElement("style")
|
||||
styleElement.id = styleId
|
||||
document.head.appendChild(styleElement)
|
||||
}
|
||||
|
||||
const {
|
||||
light: lightVars,
|
||||
dark: darkVars,
|
||||
theme: themeVars,
|
||||
} = registryTheme.cssVars
|
||||
|
||||
let cssText = ":root {\n"
|
||||
// Add theme vars (shared across light/dark).
|
||||
if (themeVars) {
|
||||
Object.entries(themeVars).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
cssText += ` --${key}: ${value};\n`
|
||||
}
|
||||
})
|
||||
}
|
||||
// Add light mode vars.
|
||||
if (lightVars) {
|
||||
Object.entries(lightVars).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
cssText += ` --${key}: ${value};\n`
|
||||
}
|
||||
})
|
||||
}
|
||||
cssText += "}\n\n"
|
||||
|
||||
cssText += ".dark {\n"
|
||||
if (darkVars) {
|
||||
Object.entries(darkVars).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
cssText += ` --${key}: ${value};\n`
|
||||
}
|
||||
})
|
||||
}
|
||||
cssText += "}\n"
|
||||
|
||||
styleElement.textContent = cssText
|
||||
}, [registryTheme])
|
||||
|
||||
// Handle menu color inversion by adding/removing dark class to elements with cn-menu-target.
|
||||
React.useEffect(() => {
|
||||
if (!menuColor) {
|
||||
return
|
||||
}
|
||||
|
||||
const updateMenuElements = () => {
|
||||
const menuElements = document.querySelectorAll(".cn-menu-target")
|
||||
menuElements.forEach((element) => {
|
||||
if (menuColor === "inverted") {
|
||||
element.classList.add("dark")
|
||||
} else {
|
||||
element.classList.remove("dark")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Update existing menu elements.
|
||||
updateMenuElements()
|
||||
|
||||
// Watch for new menu elements being added to the DOM.
|
||||
const observer = new MutationObserver(() => {
|
||||
updateMenuElements()
|
||||
})
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [menuColor])
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
113
apps/v4/app/(create)/components/font-picker.tsx
Normal file
113
apps/v4/app/(create)/components/font-picker.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from "@/registry/bases/radix/ui/item"
|
||||
import { type FontValue } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerLabel,
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerSeparator,
|
||||
PickerTrigger,
|
||||
} from "@/app/(create)/components/picker"
|
||||
import { type Font } from "@/app/(create)/lib/fonts"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
|
||||
export function FontPicker({
|
||||
fonts,
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
fonts: readonly Font[]
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const currentFont = React.useMemo(
|
||||
() => fonts.find((font) => font.value === params.font),
|
||||
[fonts, params.font]
|
||||
)
|
||||
const groupedFonts = React.useMemo(() => {
|
||||
const groups = new Map<Font["type"], Font[]>()
|
||||
|
||||
for (const font of fonts) {
|
||||
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])
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
<PickerTrigger>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-xs text-muted-foreground">Font</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentFont?.name}
|
||||
</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 }}
|
||||
>
|
||||
Aa
|
||||
</div>
|
||||
</PickerTrigger>
|
||||
<PickerContent
|
||||
anchor={isMobile ? anchorRef : undefined}
|
||||
side={isMobile ? "top" : "right"}
|
||||
align={isMobile ? "center" : "start"}
|
||||
className="max-h-96"
|
||||
>
|
||||
<PickerRadioGroup
|
||||
value={currentFont?.value}
|
||||
onValueChange={(value) => {
|
||||
setParams({ font: value as FontValue })
|
||||
}}
|
||||
>
|
||||
{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="font"
|
||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import Script from "next/script"
|
||||
import { Button } from "@/examples/base/ui/button"
|
||||
import { Redo02Icon, Undo02Icon } from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { useHistory } from "@/app/(app)/create/hooks/use-history"
|
||||
import { useHistory } from "@/app/(create)/hooks/use-history"
|
||||
|
||||
export const UNDO_FORWARD_TYPE = "undo-forward"
|
||||
export const REDO_FORWARD_TYPE = "redo-forward"
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { iconLibraries, type IconLibraryName } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
||||
import { LockButton } from "@/app/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/create/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
} from "@/app/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
|
||||
const logos = {
|
||||
lucide: (
|
||||
@@ -1,3 +1,75 @@
|
||||
"use client"
|
||||
|
||||
export { IconPlaceholder } from "@/app/(app)/create/components/icon-placeholder"
|
||||
import { lazy, Suspense } from "react"
|
||||
import { SquareIcon } from "lucide-react"
|
||||
import type { IconLibraryName } from "shadcn/icons"
|
||||
|
||||
import { useDesignSystemSearchParams } from "@/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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type Base } from "@/registry/bases"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/styles/base-nova/ui/collapsible"
|
||||
} from "@/examples/base/ui/collapsible"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -20,9 +15,14 @@ import {
|
||||
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"
|
||||
} from "@/examples/base/ui/sidebar"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type Base } from "@/registry/bases"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
import { groupItemsByType } from "@/app/(create)/lib/utils"
|
||||
|
||||
const cachedGroupedItems = React.cache(
|
||||
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
useLocks,
|
||||
type LockableParam,
|
||||
} from "@/app/(app)/create/hooks/use-locks"
|
||||
import { useLocks, type LockableParam } from "@/app/(create)/hooks/use-locks"
|
||||
|
||||
export function LockButton({
|
||||
param,
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type Button } from "@/examples/base/ui/button"
|
||||
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,
|
||||
@@ -14,13 +14,12 @@ import {
|
||||
PickerSeparator,
|
||||
PickerShortcut,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/create/components/picker"
|
||||
import { useActionMenuTrigger } from "@/app/(app)/create/hooks/use-action-menu"
|
||||
import { useHistory } from "@/app/(app)/create/hooks/use-history"
|
||||
import { useOpenPresetTrigger } from "@/app/(app)/create/hooks/use-open-preset"
|
||||
import { useRandom } from "@/app/(app)/create/hooks/use-random"
|
||||
import { useReset } from "@/app/(app)/create/hooks/use-reset"
|
||||
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
|
||||
} from "@/app/(create)/components/picker"
|
||||
import { useActionMenuTrigger } from "@/app/(create)/hooks/use-action-menu"
|
||||
import { useHistory } from "@/app/(create)/hooks/use-history"
|
||||
import { useRandom } from "@/app/(create)/hooks/use-random"
|
||||
import { useReset } from "@/app/(create)/hooks/use-reset"
|
||||
import { useThemeToggle } from "@/app/(create)/hooks/use-theme-toggle"
|
||||
|
||||
const APPLE_PLATFORM_REGEX = /Mac|iPhone|iPad|iPod/
|
||||
|
||||
@@ -28,7 +27,6 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
||||
const [isMac, setIsMac] = React.useState(false)
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
|
||||
const { openActionMenu } = useActionMenuTrigger()
|
||||
const { openPreset } = useOpenPresetTrigger()
|
||||
const { randomize } = useRandom()
|
||||
const { toggleTheme } = useThemeToggle()
|
||||
const { setShowResetDialog } = useReset()
|
||||
@@ -57,9 +55,6 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
||||
Navigate...
|
||||
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerItem onClick={openPreset}>
|
||||
Open Preset... <PickerShortcut>O</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerItem onClick={randomize}>
|
||||
Shuffle <PickerShortcut>R</PickerShortcut>
|
||||
</PickerItem>
|
||||
@@ -78,7 +73,7 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
||||
</PickerItem>
|
||||
<PickerSeparator />
|
||||
<PickerItem onClick={() => setShowResetDialog(true)}>
|
||||
Reset <PickerShortcut>⇧R</PickerShortcut>
|
||||
Reset
|
||||
</PickerItem>
|
||||
</PickerGroup>
|
||||
</PickerContent>
|
||||
166
apps/v4/app/(create)/components/menu-picker.tsx
Normal file
166
apps/v4/app/(create)/components/menu-picker.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { useMounted } from "@/hooks/use-mounted"
|
||||
import { type MenuColorValue } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
|
||||
const MENU_OPTIONS = [
|
||||
{
|
||||
value: "default" as const,
|
||||
label: "Default",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="128"
|
||||
height="128"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
className="size-4 text-foreground"
|
||||
>
|
||||
<path
|
||||
d="M2 11.5C2 7.02166 2 4.78249 3.39124 3.39124C4.78249 2 7.02166 2 11.5 2C15.9783 2 18.2175 2 19.6088 3.39124C21 4.78249 21 7.02166 21 11.5C21 15.9783 21 18.2175 19.6088 19.6088C18.2175 21 15.9783 21 11.5 21C7.02166 21 4.78249 21 3.39124 19.6088C2 18.2175 2 15.9783 2 11.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M8.5 11.5L14.5001 11.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.5 15H13.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 8H15.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "inverted" as const,
|
||||
label: "Inverted",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="128"
|
||||
height="128"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
className="size-4 fill-background"
|
||||
>
|
||||
<path
|
||||
d="M2 11.5C2 7.02166 2 4.78249 3.39124 3.39124C4.78249 2 7.02166 2 11.5 2C15.9783 2 18.2175 2 19.6088 3.39124C21 4.78249 21 7.02166 21 11.5C21 15.9783 21 18.2175 19.6088 19.6088C18.2175 21 15.9783 21 11.5 21C7.02166 21 4.78249 21 3.39124 19.6088C2 18.2175 2 15.9783 2 11.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M8.5 11.5L14.5001 11.5"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.5 15H13.5"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 8H15.5"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export function MenuColorPicker({
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const mounted = useMounted()
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
const currentMenu = MENU_OPTIONS.find(
|
||||
(menu) => menu.value === params.menuColor
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
<PickerTrigger disabled={mounted && resolvedTheme === "dark"}>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-xs text-muted-foreground">Menu Color</div>
|
||||
<div className="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">
|
||||
{currentMenu?.icon}
|
||||
</div>
|
||||
</PickerTrigger>
|
||||
<PickerContent
|
||||
anchor={isMobile ? anchorRef : undefined}
|
||||
side={isMobile ? "top" : "right"}
|
||||
align={isMobile ? "center" : "start"}
|
||||
>
|
||||
<PickerRadioGroup
|
||||
value={currentMenu?.value}
|
||||
onValueChange={(value) => {
|
||||
setParams({ menuColor: value as MenuColorValue })
|
||||
}}
|
||||
>
|
||||
<PickerGroup>
|
||||
{MENU_OPTIONS.map((menu) => (
|
||||
<PickerRadioItem
|
||||
key={menu.value}
|
||||
value={menu.value}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
{menu.label}
|
||||
</PickerRadioItem>
|
||||
))}
|
||||
</PickerGroup>
|
||||
</PickerRadioGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
<LockButton
|
||||
param="menuColor"
|
||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
apps/v4/app/(create)/components/mode-switcher.tsx
Normal file
87
apps/v4/app/(create)/components/mode-switcher.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Script from "next/script"
|
||||
import { Button } from "@/examples/base/ui/button"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useThemeToggle } from "@/app/(create)/hooks/use-theme-toggle"
|
||||
|
||||
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
|
||||
|
||||
export function ModeSwitcher({
|
||||
variant = "ghost",
|
||||
className,
|
||||
}: {
|
||||
variant?: React.ComponentProps<typeof Button>["variant"]
|
||||
className?: React.ComponentProps<typeof Button>["className"]
|
||||
}) {
|
||||
const { toggleTheme } = useThemeToggle()
|
||||
|
||||
return (
|
||||
<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
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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"
|
||||
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
|
||||
|
||||
function Picker({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
@@ -19,7 +19,7 @@ function PickerTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
|
||||
<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",
|
||||
"relative w-40 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}
|
||||
@@ -4,7 +4,7 @@ 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"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
|
||||
export function PresetHandler() {
|
||||
const router = useRouter()
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/create/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
} from "@/app/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
|
||||
export function PresetPicker({
|
||||
presets,
|
||||
@@ -31,10 +31,8 @@ export function PresetPicker({
|
||||
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
|
||||
@@ -45,10 +43,8 @@ export function PresetPicker({
|
||||
params.style,
|
||||
params.baseColor,
|
||||
params.theme,
|
||||
params.chartColor,
|
||||
params.iconLibrary,
|
||||
params.font,
|
||||
params.fontHeading,
|
||||
params.menuAccent,
|
||||
params.menuColor,
|
||||
params.radius,
|
||||
@@ -71,10 +67,8 @@ export function PresetPicker({
|
||||
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,
|
||||
@@ -112,6 +106,13 @@ export function PresetPicker({
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{style?.icon && (
|
||||
<div className="flex size-4 shrink-0 items-center justify-center">
|
||||
{React.cloneElement(style.icon, {
|
||||
className: "size-4",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{preset.description}
|
||||
</div>
|
||||
</PickerRadioItem>
|
||||
142
apps/v4/app/(create)/components/preview.tsx
Normal file
142
apps/v4/app/(create)/components/preview.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { CMD_K_FORWARD_TYPE } from "@/app/(create)/components/action-menu"
|
||||
import {
|
||||
REDO_FORWARD_TYPE,
|
||||
UNDO_FORWARD_TYPE,
|
||||
} from "@/app/(create)/components/history-buttons"
|
||||
import { DARK_MODE_FORWARD_TYPE } from "@/app/(create)/components/mode-switcher"
|
||||
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(create)/components/random-button"
|
||||
import { sendToIframe } from "@/app/(create)/hooks/use-iframe-sync"
|
||||
import {
|
||||
serializeDesignSystemSearchParams,
|
||||
useDesignSystemSearchParams,
|
||||
} from "@/app/(create)/lib/search-params"
|
||||
|
||||
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
|
||||
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
|
||||
|
||||
// Hoisted — only uses module-level constants, no component state. (rendering-hoist-jsx)
|
||||
function handleMessage(event: MessageEvent) {
|
||||
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 === 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
312
apps/v4/app/(create)/components/project-form.tsx
Normal file
312
apps/v4/app/(create)/components/project-form.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Button } from "@/examples/base/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/examples/base/ui/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldTitle,
|
||||
} from "@/examples/base/ui/field"
|
||||
import { RadioGroup, RadioGroupItem } from "@/examples/base/ui/radio-group"
|
||||
import { Switch } from "@/examples/base/ui/switch"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/examples/base/ui/tabs"
|
||||
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 { usePresetCode } from "@/app/(create)/hooks/use-design-system"
|
||||
import {
|
||||
useDesignSystemSearchParams,
|
||||
type DesignSystemSearchParams,
|
||||
} from "@/app/(create)/lib/search-params"
|
||||
import {
|
||||
getFramework,
|
||||
getTemplateValue,
|
||||
NO_MONOREPO_FRAMEWORKS,
|
||||
TEMPLATES,
|
||||
} from "@/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 && !process.env.NEXT_PUBLIC_RC
|
||||
? {
|
||||
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="min-w-0 sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick a template and configure your project. Available for all major
|
||||
React frameworks.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel className="sr-only">Template</FieldLabel>
|
||||
<TemplateGrid template={params.template} setParams={setParams} />
|
||||
</Field>
|
||||
<FieldSeparator />
|
||||
<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-foreground [&_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 />
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="rtl">
|
||||
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
|
||||
Enable RTL support
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id="rtl"
|
||||
checked={params.rtl}
|
||||
onCheckedChange={(checked) =>
|
||||
setParams({ rtl: checked === true })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="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-lg border bg-surface"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1 py-1">
|
||||
<TabsList className="font-mono">
|
||||
{PACKAGE_MANAGERS.map((manager) => {
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={manager}
|
||||
value={manager}
|
||||
className="data-[state=active]:shadow-none"
|
||||
>
|
||||
{manager}
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Copy01Icon} />
|
||||
)}
|
||||
<span className="sr-only">Copy command</span>
|
||||
</Button>
|
||||
</div>
|
||||
{Object.entries(commands).map(([key, cmd]) => {
|
||||
return (
|
||||
<TabsContent key={key} value={key}>
|
||||
<div className="relative overflow-hidden border-t border-border/50 bg-surface px-3 py-3 text-surface-foreground">
|
||||
<div className="no-scrollbar overflow-x-auto">
|
||||
<code className="font-mono text-sm whitespace-nowrap">
|
||||
{cmd}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</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-3 gap-2"
|
||||
>
|
||||
{TEMPLATES.map((item) => (
|
||||
<FieldLabel
|
||||
key={item.value}
|
||||
htmlFor={`template-${item.value}`}
|
||||
className="py-1"
|
||||
>
|
||||
<Field className="gap-0" orientation="horizontal">
|
||||
<FieldContent className="flex flex-col items-center justify-center gap-2">
|
||||
<div
|
||||
className="size-6 text-foreground [&_svg]:size-6 *:[svg]:text-foreground!"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.logo,
|
||||
}}
|
||||
></div>
|
||||
<FieldTitle className="text-xs">{item.title}</FieldTitle>
|
||||
</FieldContent>
|
||||
<RadioGroupItem
|
||||
value={item.value}
|
||||
id={`template-${item.value}`}
|
||||
className="sr-only absolute"
|
||||
/>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { RADII, type RadiusValue } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
||||
import { LockButton } from "@/app/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
PickerRadioItem,
|
||||
PickerSeparator,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/create/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
} from "@/app/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
|
||||
export function RadiusPicker({
|
||||
isMobile,
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import Script from "next/script"
|
||||
import { Button } from "@/examples/base/ui/button"
|
||||
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"
|
||||
import { useRandom } from "@/app/(create)/hooks/use-random"
|
||||
|
||||
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
|
||||
|
||||
@@ -28,7 +27,7 @@ export function RandomButton({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="w-full truncate text-center font-medium">Shuffle</span>
|
||||
<span className="w-full text-center font-medium">Shuffle</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -41,7 +40,7 @@ export function RandomizeScript() {
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
// Forward r key (shuffle) and Shift+R (reset).
|
||||
// Forward R key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.key === 'r' || e.key === 'R') && !e.metaKey && !e.ctrlKey) {
|
||||
if (
|
||||
@@ -54,11 +53,8 @@ export function RandomizeScript() {
|
||||
}
|
||||
e.preventDefault();
|
||||
if (window.parent && window.parent !== window) {
|
||||
var type = e.shiftKey
|
||||
? '${RESET_FORWARD_TYPE}'
|
||||
: '${RANDOMIZE_FORWARD_TYPE}';
|
||||
window.parent.postMessage({
|
||||
type: type,
|
||||
type: '${RANDOMIZE_FORWARD_TYPE}',
|
||||
key: e.key
|
||||
}, '*');
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/styles/base-nova/ui/alert-dialog"
|
||||
import { useReset } from "@/app/(app)/create/hooks/use-reset"
|
||||
} from "@/examples/base/ui/alert-dialog"
|
||||
|
||||
import { useReset } from "@/app/(create)/hooks/use-reset"
|
||||
|
||||
export function ResetDialog() {
|
||||
const { showResetDialog, setShowResetDialog, confirmReset } = useReset()
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Button } from "@/examples/base/ui/button"
|
||||
import { Share03Icon, Tick02Icon } from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
|
||||
export function ShareButton() {
|
||||
const [params] = useDesignSystemSearchParams()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user