Compare commits

..

1 Commits

Author SHA1 Message Date
shadcn
86479e0a30 fix: init e2e tests 2026-03-10 17:21:19 +04:00
8328 changed files with 303204 additions and 230948 deletions

View File

@@ -9,6 +9,5 @@
"WebFetch(domain:github.com)"
],
"deny": []
},
"outputStyle": "Explanatory"
}
}

View File

@@ -1,41 +0,0 @@
{
"name": "shadcn",
"displayName": "shadcn/ui",
"version": "1.0.0",
"description": "UI component and design system framework. Search registries, install components as source code, and audit your project.",
"author": {
"name": "shadcn"
},
"homepage": "https://ui.shadcn.com",
"repository": "https://github.com/shadcn-ui/ui",
"license": "MIT",
"logo": "skills/shadcn/assets/shadcn.png",
"keywords": [
"shadcn",
"shadcn-ui",
"ui",
"components",
"tailwind",
"tailwindcss",
"radix",
"react",
"design-system",
"registry",
"mcp"
],
"category": "developer-tools",
"tags": [
"ui",
"components",
"design-system",
"react",
"tailwind"
],
"skills": "./skills/",
"mcpServers": {
"shadcn": {
"command": "npx",
"args": ["shadcn@latest", "mcp"]
}
}
}

View File

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

78
.github/workflows/deprecated.yml vendored Normal file
View 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');
}
}

View File

@@ -3,7 +3,7 @@ name: Write Beta Release comment
on:
workflow_run:
workflows: ["Release"]
workflows: ["Release - Beta"]
types:
- completed
@@ -11,13 +11,12 @@ jobs:
comment:
if: |
github.repository_owner == 'shadcn-ui' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
name: Write comment to the PR
steps:
- name: "Comment on PR"
uses: actions/github-script@v7
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -54,7 +53,7 @@ jobs:
```
- name: "Remove the autorelease label once published"
uses: actions/github-script@v7
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

64
.github/workflows/prerelease.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
# Adapted from create-t3-app.
name: Release - Beta
on:
pull_request:
types: [labeled]
branches:
- main
permissions:
id-token: write
contents: read
jobs:
prerelease:
if: |
github.repository_owner == 'shadcn-ui' &&
contains(github.event.pull_request.labels.*.name, '🚀 autorelease')
name: Build & Publish a beta release to NPM
runs-on: ubuntu-latest
environment: Preview
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Publish Beta to NPM
run: pnpm pub:beta
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js

View File

@@ -2,81 +2,24 @@
name: Release
run-name: ${{ github.event_name == 'pull_request' && format('Release Beta - PR {0}', github.event.number) || 'Release Stable' }}
on:
pull_request:
types: [labeled]
branches:
- main
push:
branches:
- main
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
prerelease:
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && contains(github.event.pull_request.labels.*.name, '🚀 autorelease') }}
name: Publish Beta to NPM
runs-on: ubuntu-latest
environment: Preview
permissions:
id-token: write
contents: read
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Publish Beta to NPM
run: pnpm pub:beta
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js
release:
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
name: Create Version PR or Publish Stable Release
if: ${{ github.repository_owner == 'shadcn-ui' }}
name: Create a PR for release workflow
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
pull-requests: write
steps:
- name: Checkout Repo
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 0
@@ -104,19 +47,10 @@ jobs:
- name: Build the package
run: pnpm shadcn:build
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
git_user_signingkey: true
git_commit_gpgsign: true
git_tag_gpgsign: true
- name: Create Version PR or Publish to NPM
id: changesets
uses: changesets/action@v1
with:
setupGitUser: false
commit: "chore(release): version packages"
title: "chore(release): version packages"
version: node .github/changeset-version.js

View File

@@ -1,75 +0,0 @@
name: Signed commits
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- ready_for_review
permissions:
pull-requests: write
jobs:
signed-commits:
if: github.repository_owner == 'shadcn-ui'
runs-on: ubuntu-latest
name: Signed commits
steps:
- name: Check PR commits
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const body = "Can you sign the commits please? See https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits. Thank you."
const { owner, repo } = context.repo
const pullNumber = context.payload.pull_request.number
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
})
const unsignedCommits = commits.filter((commit) => {
return commit.commit.verification?.reason === "unsigned"
})
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pullNumber,
per_page: 100,
})
const existingComments = comments.filter((comment) => {
return comment.user.type === "Bot" && comment.body.trim() === body
})
if (unsignedCommits.length > 0) {
core.info(`Found ${unsignedCommits.length} unsigned commits.`)
if (existingComments.length === 0) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pullNumber,
body,
})
}
return
}
core.info("All commits are signed.")
for (const comment of existingComments) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id,
})
}

View File

@@ -39,9 +39,6 @@ jobs:
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: pnpm install

View File

@@ -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
View File

@@ -15,7 +15,6 @@ build
# misc
.DS_Store
.eslintcache
*.pem
# debug
@@ -44,4 +43,3 @@ tsconfig.tsbuildinfo
.notes
.playwright-mcp
shadcn-workspace
.codex-artifacts

View File

@@ -5,4 +5,3 @@ build
.contentlayer
**/fixtures
deprecated
apps/v4/registry/styles/**/*.css

View File

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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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 (

View File

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

View File

@@ -1,5 +1,5 @@
import { Button } from "@/styles/radix-nova/ui/button"
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Button } from "@/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 (

View File

@@ -1,5 +1,5 @@
import { Card, CardContent } from "@/styles/radix-nova/ui/card"
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
import { Card, CardContent } from "@/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 = [
{

View File

@@ -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])

View File

@@ -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"

View File

@@ -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)

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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: [

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -3,12 +3,15 @@ import Image from "next/image"
import Link from "next/link"
import { Announcement } from "@/components/announcement"
import { ExamplesNav } from "@/components/examples-nav"
import {
PageActions,
PageHeader,
PageHeaderDescription,
PageHeaderHeading,
} from "@/components/page-header"
import { PageNav } from "@/components/page-nav"
import { ThemeSelector } from "@/components/theme-selector"
import { Button } from "@/registry/new-york-v4/ui/button"
import { RootComponents } from "./components"
@@ -60,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

View File

@@ -1,216 +0,0 @@
"use client"
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-sera/ui/card"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/styles/base-sera/ui/input-group"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/styles/base-sera/ui/pagination"
import { Progress, ProgressValue } from "@/styles/base-sera/ui/progress"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/styles/base-sera/ui/table"
const ARTICLE_ROWS = [
{
title: "The Future of Sustainable Architecture",
wordProgress: "1.4k / 2.6k words",
author: "Elena Rostova",
issue: "Summer 2024",
status: "in-revision",
statusLabel: "In revision",
progress: 45,
},
{
title: "Brutalism's Second Act",
wordProgress: "2.1k / 2.5k words",
author: "Marcus Chen",
issue: "Summer 2024",
status: "final-edit",
statusLabel: "Final edit",
progress: 90,
},
{
title: "The Typography of Public Spaces",
wordProgress: "0.5k / 1.5k words",
author: "Sarah Jenkins",
issue: "Autumn 2024",
status: "drafting",
statusLabel: "Drafting",
progress: 20,
},
{
title: "Rethinking Urban Canopies",
wordProgress: "1.8k / 1.8k words",
author: "David O'Connor",
issue: "Summer 2024",
status: "published",
statusLabel: "Published",
progress: 100,
},
{
title: "Light, Glass, and the Modern Museum",
wordProgress: "1.2k / 2.0k words",
author: "Amara Osei",
issue: "Autumn 2024",
status: "in-revision",
statusLabel: "In revision",
progress: 55,
},
{
title: "Concrete Utopias: Housing in the 21st Century",
wordProgress: "3.0k / 3.0k words",
author: "Tomás Herrera",
issue: "Summer 2024",
status: "published",
statusLabel: "Published",
progress: 100,
},
{
title: "Designing for Silence",
wordProgress: "0.8k / 2.2k words",
author: "Ingrid Solberg",
issue: "Winter 2024",
status: "drafting",
statusLabel: "Drafting",
progress: 30,
},
{
title: "The Invisible Infrastructure of Cities",
wordProgress: "2.4k / 2.8k words",
author: "James Whitfield",
issue: "Autumn 2024",
status: "final-edit",
statusLabel: "Final edit",
progress: 85,
},
] as const
const STATUS_BADGE_VARIANT = {
"in-revision": "outline",
"final-edit": "default",
drafting: "ghost",
published: "secondary",
} as const
const STATUS_DOT_CLASSNAME = {
"in-revision": "bg-amber-600/80",
"final-edit": "bg-foreground/90",
drafting: "bg-muted-foreground/60",
published: "bg-emerald-600/80",
}
export function ArticleDirectory() {
return (
<Card>
<CardHeader>
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput type="search" placeholder="Search articles..." />
</InputGroup>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Title</TableHead>
<TableHead className="w-[170px]">Author</TableHead>
<TableHead className="w-[150px]">Issue</TableHead>
<TableHead className="w-[180px]">Status</TableHead>
<TableHead className="w-[140px]">Progress</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ARTICLE_ROWS.map((row) => (
<TableRow key={row.title}>
<TableCell>
<div className="flex flex-col gap-1">
<p className="font-heading text-xl tracking-tight text-foreground">
{row.title}
</p>
<p className="text-xs text-muted-foreground">
{row.wordProgress}
</p>
</div>
</TableCell>
<TableCell>{row.author}</TableCell>
<TableCell>{row.issue}</TableCell>
<TableCell>
<Badge variant={STATUS_BADGE_VARIANT[row.status]}>
<span
className={cn(
"size-1.5 rounded-full",
STATUS_DOT_CLASSNAME[row.status]
)}
/>
{row.statusLabel}
</Badge>
</TableCell>
<TableCell>
<Progress
value={row.progress}
aria-label={`${row.progress}% complete`}
className="flex flex-row-reverse items-center **:data-[slot=progress-track]:w-16"
>
<ProgressValue>
{(formattedValue) => `${formattedValue}`}
</ProgressValue>
</Progress>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationLink
href="#"
size="icon-sm"
aria-label="Previous page"
>
<ChevronLeftIcon className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
{[1, 2, 3].map((page) => (
<PaginationItem key={page}>
<PaginationLink href="#" size="icon-sm" isActive={page === 1}>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationLink href="#" size="icon-sm" aria-label="Next page">
<ChevronRightIcon className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
)
}

View File

@@ -1,47 +0,0 @@
import { ArrowLeftIcon, PlusIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList className="justify-center md:justify-start">
<BreadcrumbItem>
<BreadcrumbLink
href="#"
className="inline-flex items-center gap-1.5"
>
<ArrowLeftIcon className="size-3" />
Editorial Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Article Directory
</h1>
</div>
<div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<Button>
<PlusIcon data-icon="inline-start" />
New Article
</Button>
</ButtonGroup>
</div>
</div>
</header>
)
}

View File

@@ -1,16 +0,0 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { ArticleDirectory as ArticleDirectoryList } from "./components/article-directory"
import { PreviewHeader } from "./components/preview-header"
export function ArticleDirectory() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container py-(--gap)">
<ArticleDirectoryList />
</div>
</div>
)
}

View File

@@ -1,56 +0,0 @@
"use client"
import * as React from "react"
import { MoveRightIcon } from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/styles/base-sera/ui/progress"
const DEMOGRAPHIC_DATA = [
{ age: "18 - 24", percentage: 22 },
{ age: "25 - 34", percentage: 64 },
{ age: "35 - 44", percentage: 12 },
{ age: "45+", percentage: 5 },
]
export function Demographics({ ...props }: React.ComponentProps<typeof Card>) {
return (
<Card {...props}>
<CardHeader>
<CardTitle className="text-2xl">Demographics</CardTitle>
<CardDescription>Reader Profile</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-10">
{DEMOGRAPHIC_DATA.map((item) => (
<Progress
key={item.age}
value={item.percentage}
aria-label={item.age}
>
<ProgressLabel>{item.age}</ProgressLabel>
<ProgressValue>
{(formattedValue) => `${formattedValue}`}
</ProgressValue>
</Progress>
))}
</CardContent>
<CardFooter>
<Button variant="link" className="w-full">
View all source <MoveRightIcon data-icon="inline-end" />
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -1,93 +0,0 @@
import { TrendingDownIcon, TrendingUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
type Metric = {
label: string
value: string
comparison: string
change: string
trend: "up" | "down"
}
const METRIC_CARDS: Metric[] = [
{
label: "Total visitors",
value: "248.5k",
comparison: "12.4%",
change: "vs last period",
trend: "up",
},
{
label: "Unique readers",
value: "182.1k",
comparison: "8.7%",
change: "vs last period",
trend: "up",
},
{
label: "Avg. time on page",
value: "3m 42s",
comparison: "1.2%",
change: "vs last period",
trend: "down",
},
{
label: "Bounce rate",
value: "42.8%",
comparison: "3.5%",
change: "vs last period",
trend: "down",
},
]
export function MetricsGrid() {
return (
<>
{METRIC_CARDS.map((metric) => (
<MetricCard
key={metric.label}
metric={metric}
className="col-span-full md:col-span-6 lg:col-span-3"
/>
))}
</>
)
}
function MetricCard({
metric,
className,
}: {
metric: Metric
className: string
}) {
return (
<Card className={cn("gap-0", className)}>
<CardContent className="flex flex-col gap-2">
<CardDescription className="text-xs uppercase">
{metric.label}
</CardDescription>
<CardTitle className="text-5xl tracking-tight lowercase">
{metric.value}
</CardTitle>
<CardDescription>
{metric.trend === "up" ? (
<TrendingUpIcon className="inline-block size-2.5 text-muted-foreground" />
) : (
<TrendingDownIcon className="inline-block size-2.5 text-muted-foreground" />
)}{" "}
<span className="text-foreground">{metric.comparison}</span>{" "}
<span>{metric.change}</span>
</CardDescription>
</CardContent>
</Card>
)
}

View File

@@ -1,103 +0,0 @@
"use client"
import * as React from "react"
import { ChevronDownIcon, DownloadIcon } from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
const EXPORT_DATE_OPTIONS = [
{
label: "Last 7 days",
value: "last-7-days",
},
{
label: "Last 30 days",
value: "last-30-days",
},
{
label: "This month",
value: "this-month",
},
{
label: "Last month",
value: "last-month",
},
]
export function PreviewHeader() {
const [selectedDateRange, setSelectedDateRange] =
React.useState("last-30-days")
const selectedDateRangeLabel = React.useMemo(() => {
const selectedOption = EXPORT_DATE_OPTIONS.find(
(option) => option.value === selectedDateRange
)
if (!selectedOption) {
return "Last 30 days"
}
return selectedOption.label
}, [selectedDateRange])
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Audience Analytics
</h1>
<div className="line-clamp-1 text-sm font-medium tracking-wider text-muted-foreground uppercase">
Editorial Performance Dashboard
</div>
</div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
className="bg-background hover:bg-background/80 data-popup-open:bg-background"
/>
}
>
{selectedDateRangeLabel}{" "}
<ChevronDownIcon data-icon="inline-end" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuRadioGroup
value={selectedDateRange}
onValueChange={setSelectedDateRange}
>
{EXPORT_DATE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button>
<DownloadIcon data-icon="inline-start" />
<span className="lg:hidden">Export</span>
<span className="hidden lg:inline">Export Report</span>
</Button>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -1,257 +0,0 @@
"use client"
import * as React from "react"
import { ArrowDownIcon, MoreHorizontalIcon } from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
import { Spinner } from "@/styles/base-sera/ui/spinner"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/styles/base-sera/ui/table"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/styles/base-sera/ui/toggle-group"
type EditorialMetric = "views" | "time" | "shares"
type EditorialRow = {
rank: number
title: string
author: string
published: string
pageviews: string
avgTime: string
}
const METRIC_LABEL: Record<EditorialMetric, string> = {
views: "VIEWS",
time: "TIME",
shares: "SHARES",
}
const EDITORIAL_ROWS: EditorialRow[] = [
{
rank: 1,
title: "The New Vanguard of Minimalist Architecture",
author: "Elena Rostova",
published: "Oct 12",
pageviews: "45.2k",
avgTime: "04:15",
},
{
rank: 2,
title: "Autumn Sartorial Code: Deconstructed Classics",
author: "Julian Vance",
published: "Oct 05",
pageviews: "38.9k",
avgTime: "03:42",
},
{
rank: 3,
title: "Interview: Director Sofia Coppola on The Aesthetics of Isolation",
author: "Marcus Trent",
published: "Sep 28",
pageviews: "31.4k",
avgTime: "06:20",
},
{
rank: 4,
title: "Sourcing Ceramics from Kyoto's Oldest Kilns",
author: "Sarah Lin",
published: "Oct 18",
pageviews: "22.1k",
avgTime: "02:55",
},
{
rank: 5,
title: "Field Notes from Copenhagen Design Week",
author: "Noah Bennett",
published: "Oct 21",
pageviews: "19.7k",
avgTime: "03:18",
},
{
rank: 6,
title: "A Studio Visit with Milan's Most Elusive Lighting Designer",
author: "Claire Duval",
published: "Oct 09",
pageviews: "17.4k",
avgTime: "04:02",
},
{
rank: 7,
title: "Collecting the New Avant-Garde in Contemporary Furniture",
author: "Tommy Rhodes",
published: "Sep 30",
pageviews: "15.9k",
avgTime: "03:36",
},
{
rank: 8,
title: "Inside Lisbon's Quiet Culinary Renaissance",
author: "Amara Iqbal",
published: "Oct 14",
pageviews: "14.2k",
avgTime: "05:08",
},
{
rank: 9,
title: "Why Slow Interiors Are Defining the Next Luxury Wave",
author: "Henry Vale",
published: "Oct 03",
pageviews: "12.7k",
avgTime: "03:11",
},
{
rank: 10,
title: "The Return of Print: Independent Magazine Covers to Watch",
author: "Mina Okafor",
published: "Sep 26",
pageviews: "11.3k",
avgTime: "02:49",
},
]
type TopEditorialProps = React.ComponentProps<typeof Card> & {
selectedMetric?: EditorialMetric
}
export function TopEditorial({
selectedMetric = "views",
...props
}: TopEditorialProps) {
const [visibleCount, setVisibleCount] = React.useState(5)
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
const hasMoreRows = visibleCount < EDITORIAL_ROWS.length
const visibleRows = EDITORIAL_ROWS.slice(0, visibleCount)
const handleLoadMore = React.useCallback(() => {
if (!hasMoreRows || isLoadingMore) {
return
}
setIsLoadingMore(true)
window.setTimeout(() => {
setVisibleCount(EDITORIAL_ROWS.length)
setIsLoadingMore(false)
}, 2000)
}, [hasMoreRows, isLoadingMore])
return (
<Card {...props}>
<CardHeader>
<div className="flex flex-col gap-(--gap) sm:flex-row">
<div className="flex flex-col gap-1.5">
<CardTitle className="text-2xl">Top Editorials</CardTitle>
<CardDescription>Ranked by engagement</CardDescription>
</div>
<ToggleGroup
aria-label="Top editorials metric selector"
value={[selectedMetric]}
variant="outline"
className="w-full sm:ml-auto sm:w-fit"
>
{(["views", "time", "shares"] as const).map((metric) => {
return (
<ToggleGroupItem key={metric} value={metric} className="flex-1">
{METRIC_LABEL[metric]}
</ToggleGroupItem>
)
})}
</ToggleGroup>
</div>
</CardHeader>
<CardContent className="flex-1 **:data-[slot=table-container]:no-scrollbar **:data-[slot=table-container]:overflow-y-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Title</TableHead>
<TableHead>Published</TableHead>
<TableHead>Page Views</TableHead>
<TableHead>Read Time</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.rank}>
<TableCell className="translate-y-1 align-text-top">
{row.rank}
</TableCell>
<TableCell>
<div className="flex flex-col gap-2">
<p className="font-heading text-xl tracking-tight text-foreground">
{row.title}
</p>
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
By {row.author}
</p>
</div>
</TableCell>
<TableCell>{row.published}</TableCell>
<TableCell>{row.pageviews}</TableCell>
<TableCell>{row.avgTime}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-xs" />}
aria-label={`Open actions for ${row.title}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Publish</DropdownMenuItem>
<DropdownMenuItem variant="destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter className="justify-center">
{hasMoreRows ? (
<Button
type="button"
variant="outline"
onClick={handleLoadMore}
disabled={isLoadingMore}
>
Load more content{" "}
{isLoadingMore ? (
<Spinner data-icon="inline-end" />
) : (
<ArrowDownIcon data-icon="inline-end" />
)}
</Button>
) : null}
</CardFooter>
</Card>
)
}

View File

@@ -1,57 +0,0 @@
"use client"
import * as React from "react"
import dynamic from "next/dynamic"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
const TrafficOverviewContent = dynamic(
() => import("./traffic-overview").then((mod) => mod.TrafficOverview),
{
ssr: false,
loading: () => <TrafficOverviewFallback />,
}
)
export function TrafficOverviewDeferred({
className,
...props
}: React.ComponentProps<typeof Card>) {
return (
<div className={className}>
<TrafficOverviewContent {...props} />
</div>
)
}
function TrafficOverviewFallback() {
return (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Traffic Overview</CardTitle>
<CardDescription>
Traffic for the last 30 days has increased by 12.4% compared to the
previous period.
</CardDescription>
</CardHeader>
<CardContent>
<div
aria-hidden="true"
className="flex h-82 w-full flex-col justify-end gap-6 overflow-hidden bg-muted/40 p-5"
>
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,155 +0,0 @@
"use client"
import { TrendingUpIcon } from "lucide-react"
import {
CartesianGrid,
Line,
LineChart,
ReferenceDot,
XAxis,
YAxis,
} from "recharts"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/styles/base-sera/ui/chart"
const TRAFFIC_OVERVIEW_DATA = [
{ date: "2025-10-01", views: 2600, unique: 1600 },
{ date: "2025-10-04", views: 4500, unique: 3000 },
{ date: "2025-10-08", views: 3500, unique: 2500 },
{ date: "2025-10-10", views: 6400, unique: 4500 },
{ date: "2025-10-13", views: 5400, unique: 4000 },
{ date: "2025-10-15", views: 8300, unique: 6500 },
{ date: "2025-10-17", views: 7400, unique: 6000 },
{ date: "2025-10-18", views: 9240, unique: 7105 },
{ date: "2025-10-22", views: 7700, unique: 6400 },
{ date: "2025-10-26", views: 8800, unique: 7000 },
{ date: "2025-10-29", views: 9800, unique: 8400 },
]
const TRAFFIC_CHART_CONFIG = {
views: {
label: "Views",
theme: {
light: "var(--chart-5)",
dark: "var(--chart-1)",
},
},
unique: {
label: "Unique",
theme: {
light: "var(--chart-1)",
dark: "var(--chart-2)",
},
},
} satisfies ChartConfig
const X_AXIS_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
})
function formatYAxisTick(value: number) {
if (value === 0) {
return "0"
}
if (value % 1000 === 0) {
return `${value / 1000}k`
}
return `${value / 1000}k`
}
function formatXAxisTick(value: string) {
const date = new Date(`${value}T00:00:00Z`)
if (Number.isNaN(date.getTime())) {
return value
}
return X_AXIS_DATE_FORMATTER.format(date)
}
export function TrafficOverview({
...props
}: React.ComponentProps<typeof Card>) {
return (
<Card {...props}>
<CardHeader>
<CardTitle className="text-2xl">Traffic Overview</CardTitle>
<CardDescription>
Traffic for the last 30 days has increased by 12.4% compared to the
previous period.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={TRAFFIC_CHART_CONFIG} className="h-82 w-full">
<LineChart data={TRAFFIC_OVERVIEW_DATA}>
<CartesianGrid
vertical={false}
strokeDasharray="3 6"
stroke="var(--border)"
/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
tickMargin={10}
tickFormatter={formatXAxisTick}
/>
<YAxis
tickLine={false}
axisLine={false}
width={44}
domain={[0, 10000]}
ticks={[0, 2500, 5000, 7500, 10000]}
tickFormatter={formatYAxisTick}
hide
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="linear"
dataKey="views"
stroke="var(--color-views)"
strokeWidth={2.2}
dot={false}
activeDot={{ r: 3.5, fill: "var(--color-views)" }}
/>
<Line
type="linear"
dataKey="unique"
stroke="var(--color-unique)"
strokeWidth={2}
strokeDasharray="4 6"
dot={false}
activeDot={false}
/>
<ReferenceDot
x="2025-10-18"
y={9240}
r={2.5}
fill="var(--color-views)"
stroke="var(--color-views)"
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -1,22 +0,0 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { Demographics } from "./components/demographics"
import { MetricsGrid } from "./components/metrics-grid"
import { PreviewHeader } from "./components/preview-header"
import { TopEditorial } from "./components/top-editorial"
import { TrafficOverviewDeferred } from "./components/traffic-overview-deferred"
export function AudienceAnalytics() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container grid grid-cols-12 gap-(--gap) py-(--gap)">
<MetricsGrid />
<TrafficOverviewDeferred className="col-span-full md:col-span-6 lg:col-span-8" />
<Demographics className="col-span-full md:col-span-6 lg:col-span-4" />
<TopEditorial className="col-span-full" />
</div>
</div>
)
}

View File

@@ -1,46 +0,0 @@
import Image from "next/image"
import { cn } from "@/lib/utils"
export function ImagePreview() {
return (
<div className="mt-8 flex flex-col overflow-hidden md:hidden">
<ImagePreviewItem name="sera-01" />
<ImagePreviewItem name="sera-03" />
<ImagePreviewItem name="sera-02" />
<ImagePreviewItem name="sera-06" />
</div>
)
}
function ImagePreviewItem({
name,
className,
}: {
name: string
className?: string
}) {
return (
<div
className={cn(
"theme-taupe overflow-hidden bg-muted px-4 py-2 first:pt-4 last:pb-4",
className
)}
>
<Image
src={`/images/${name}-light.png`}
alt={name}
width={1440}
height={900}
className="dark:hidden"
/>
<Image
src={`/images/${name}-dark.png`}
alt={name}
width={1440}
height={900}
className="hidden dark:block"
/>
</div>
)
}

View File

@@ -1,148 +0,0 @@
"use client"
import * as React from "react"
import dynamic from "next/dynamic"
type LazyPreviewName =
| "articleDirectory"
| "emptyState"
| "editArticle"
| "mediaLibrary"
| "mediaLibraryTable"
const PREVIEW_MIN_HEIGHTS: Record<LazyPreviewName, number> = {
articleDirectory: 760,
emptyState: 560,
editArticle: 980,
mediaLibrary: 880,
mediaLibraryTable: 980,
}
const ArticleDirectoryPreview = dynamic(
() => import("../article-directory").then((mod) => mod.ArticleDirectory),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.articleDirectory} />
),
}
)
const EmptyStatePreview = dynamic(
() => import("../empty-state").then((mod) => mod.EmptyState),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.emptyState} />
),
}
)
const EditArticlePreview = dynamic(
() => import("../edit-article").then((mod) => mod.EditArticle),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.editArticle} />
),
}
)
const MediaLibraryPreview = dynamic(
() => import("../media-library").then((mod) => mod.MediaLibrary),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.mediaLibrary} />
),
}
)
const MediaLibraryTablePreview = dynamic(
() => import("../media-library-table").then((mod) => mod.MediaLibraryTable),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.mediaLibraryTable} />
),
}
)
const PREVIEW_COMPONENTS: Record<LazyPreviewName, React.ComponentType> = {
articleDirectory: ArticleDirectoryPreview,
emptyState: EmptyStatePreview,
editArticle: EditArticlePreview,
mediaLibrary: MediaLibraryPreview,
mediaLibraryTable: MediaLibraryTablePreview,
}
export function LazyPreview({ name }: { name: LazyPreviewName }) {
const containerRef = React.useRef<HTMLDivElement>(null)
const [shouldRender, setShouldRender] = React.useState(false)
const PreviewComponent = PREVIEW_COMPONENTS[name]
React.useEffect(() => {
if (shouldRender) {
return
}
const container = containerRef.current
if (!container || !("IntersectionObserver" in window)) {
setShouldRender(true)
return
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) {
return
}
setShouldRender(true)
observer.disconnect()
},
{
rootMargin: "800px 0px",
}
)
observer.observe(container)
return () => observer.disconnect()
}, [shouldRender])
return (
<div ref={containerRef}>
{shouldRender ? (
<PreviewComponent />
) : (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS[name]} />
)}
</div>
)
}
function PreviewPlaceholder({ minHeight }: { minHeight: number }) {
return (
<div
aria-hidden="true"
className="preview theme-taupe @container/preview w-full flex-1 bg-muted p-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:p-6 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)]"
style={{ minHeight }}
>
<div className="container flex flex-col gap-(--gap) py-(--gap)">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-3">
<div className="h-5 w-44 bg-background/80" />
<div className="h-3 w-56 max-w-full bg-background/60" />
</div>
<div className="hidden h-8 w-28 bg-background/70 sm:block" />
</div>
<div className="grid grid-cols-1 gap-(--gap) md:grid-cols-3">
<div className="min-h-48 bg-background/70 md:col-span-2" />
<div className="min-h-48 bg-background/70" />
</div>
</div>
</div>
)
}

View File

@@ -1,59 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const THEME_OPTIONS = [
{ label: "Taupe", value: "theme-taupe" },
{ label: "Neutral", value: "theme-neutral" },
{ label: "Stone", value: "theme-stone" },
{ label: "Zinc", value: "theme-zinc" },
{ label: "Mauve", value: "theme-mauve" },
{ label: "Olive", value: "theme-olive" },
{ label: "Mist", value: "theme-mist" },
] as const
const DEFAULT_THEME = "theme-taupe"
function applyThemeToPreviews(theme: string) {
const previewElements = document.querySelectorAll<HTMLElement>(".preview")
previewElements.forEach((element) => {
THEME_OPTIONS.forEach((option) => {
element.classList.remove(option.value)
})
element.classList.add(theme)
})
}
export function ThemeSwitcher() {
const [theme, setTheme] = React.useState<string>(DEFAULT_THEME)
React.useEffect(() => {
applyThemeToPreviews(theme)
}, [theme])
return (
<div className="fixed inset-x-0 bottom-8 z-50 flex justify-center px-4">
<div className="w-full max-w-[60vw] rounded-full border-0 bg-neutral-950/50 p-1.5 shadow-xl backdrop-blur-xl sm:max-w-fit">
<div className="no-scrollbar flex snap-x snap-mandatory items-center overflow-x-auto">
{THEME_OPTIONS.map((option) => (
<button
data-active={theme === option.value}
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
}}
className="shrink-0 snap-center rounded-full px-3 py-1.5 text-sm font-medium text-neutral-300 outline-hidden transition-colors select-none hover:text-neutral-100 data-active:bg-neutral-500 data-active:text-neutral-100"
>
{option.label}
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,337 +0,0 @@
"use client"
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
ChevronDownIcon,
Code2Icon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ImageIcon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
RedoIcon,
StrikethroughIcon,
TypeIcon,
UnderlineIcon,
UndoIcon,
} from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import {
ButtonGroup,
ButtonGroupSeparator,
} from "@/styles/base-sera/ui/button-group"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
import {
Field,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/styles/base-sera/ui/field"
import { Input } from "@/styles/base-sera/ui/input"
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/styles/base-sera/ui/progress"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/styles/base-sera/ui/select"
import { Textarea } from "@/styles/base-sera/ui/textarea"
type Milestone = {
name: string
complete: boolean
note?: string
}
const MILESTONES: Milestone[] = [
{
name: "Outline & Commissioning",
complete: true,
},
{
name: "First Draft Submitted",
complete: true,
},
{
name: "Review & Revisions",
complete: false,
note: "Waiting on editor",
},
{
name: "Final Copy Edit",
complete: false,
},
{
name: "Art Direction & Layout",
complete: false,
},
]
const ISSUES = [
{ label: "Spring Issue 2024", value: "spring-2024" },
{ label: "Summer Issue 2024", value: "summer-2024" },
{ label: "Autumn Issue 2024", value: "autumn-2024" },
{ label: "Winter Issue 2024", value: "winter-2024" },
]
export function EditorWorkspace() {
return (
<div className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_300px]">
<section className="flex flex-col border border-border/70 bg-background">
<div className="flex border-b p-2">
<ButtonGroup>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm">
Normal Text
<ChevronDownIcon data-icon="inline-end" />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem>
<TypeIcon />
Normal Text
</DropdownMenuItem>
<DropdownMenuItem>
<Heading1Icon />
Heading 1
</DropdownMenuItem>
<DropdownMenuItem>
<Heading2Icon />
Heading 2
</DropdownMenuItem>
<DropdownMenuItem>
<Heading3Icon />
Heading 3
</DropdownMenuItem>
<DropdownMenuItem>
<ListIcon />
Bullet List
</DropdownMenuItem>
<DropdownMenuItem>
<ListOrderedIcon />
Numbered List
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ButtonGroupSeparator className="mx-2 data-vertical:h-4 data-vertical:self-center" />
<Button variant="ghost" size="icon-sm" aria-label="Bold">
<BoldIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Italic">
<ItalicIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Underline">
<UnderlineIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Strikethrough"
className="hidden md:flex"
>
<StrikethroughIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Code"
className="hidden md:flex"
>
<Code2Icon />
</Button>
<ButtonGroupSeparator className="mx-2 hidden md:flex data-vertical:h-4 data-vertical:self-center" />
<Button
variant="ghost"
size="icon-sm"
aria-label="Align Left"
className="hidden md:flex"
>
<AlignLeftIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Align Center"
className="hidden md:flex"
>
<AlignCenterIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Align Right"
className="hidden md:flex"
>
<AlignRightIcon />
</Button>
<ButtonGroupSeparator className="mx-2 hidden md:flex data-vertical:h-4 data-vertical:self-center" />
<Button
variant="ghost"
size="icon-sm"
aria-label="Link"
className="hidden md:flex"
>
<LinkIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Image"
className="hidden md:flex"
>
<ImageIcon />
</Button>
</ButtonGroup>
<ButtonGroup className="ml-auto">
<Button variant="ghost" size="icon-sm" aria-label="Undo">
<UndoIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Redo">
<RedoIcon />
</Button>
</ButtonGroup>
</div>
<div className="mx-auto flex max-w-2xl flex-1 flex-col gap-8 px-10 py-10 leading-loose md:px-14 lg:py-18">
<h1 className="font-heading text-4xl leading-12 font-medium tracking-wide uppercase">
The Future of Sustainable Architecture
</h1>
<p>
As cities continue to expand at an unprecedented rate, the
architectural paradigm is shifting from mere expansion to
sustainable integration. The concrete jungles of the 20th century
are making way for structures that breathe, adapt, and give back to
their environments.
</p>
<p>
Historically, urban development has been a zero-sum game with
nature.
</p>
<h2 className="font-heading text-2xl tracking-wide uppercase">
The Living Building Challenge
</h2>
<p>
Sterling&apos;s latest project in downtown Seattle is a testament to
this new philosophy. &quot;We are no longer designing static
structures,&quot; Sterling explained during a recent site visit.
&quot;We are engineering localized ecosystems.&quot;
</p>
<p>
The building features a facade of responsive biomaterials that
adjust their porosity based on humidity and temperature,
significantly reducing the need for artificial climate control.
Rainwater is not merely channeled away but captured, filtered
through a series of integrated rooftop wetlands, and reused within
the building&apos;s greywater system.
</p>
<p className="text-sm text-muted-foreground">
This shift requires more than just innovative materials; it demands
a fundamental change in how we value space. Check with engineering
team for specific stats.
</p>
</div>
</section>
<aside className="grid grid-cols-12 gap-(--gap) xl:flex xl:flex-col">
<Card className="col-span-full md:col-span-6 lg:col-span-4">
<CardHeader>
<CardTitle>Article Details</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>Issue</FieldLabel>
<Select items={ISSUES} defaultValue="summer-2024">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ISSUES.map((issue) => (
<SelectItem key={issue.value} value={issue.value}>
{issue.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Author</FieldLabel>
<Input defaultValue="Elena Rostova" />
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card className="col-span-full md:col-span-6 lg:col-span-4">
<CardHeader>
<CardTitle>Publication Flow</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldLegend>Required Milestones</FieldLegend>
<Field>
{MILESTONES.map((milestone) => (
<Field key={milestone.name} orientation="horizontal">
<Checkbox
defaultChecked={milestone.complete}
name={milestone.name}
id={milestone.name}
/>
<FieldLabel htmlFor={milestone.name}>
{milestone.name}
</FieldLabel>
</Field>
))}
</Field>
</FieldSet>
<Field>
<FieldLabel>Add note for editor</FieldLabel>
<Textarea placeholder="This article needs to be revised for clarity and accuracy." />
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card className="col-span-full lg:col-span-4">
<CardHeader>
<CardTitle>Word Count</CardTitle>
</CardHeader>
<CardContent>
<Progress value={70}>
<ProgressLabel>1,402 / 2,000 words</ProgressLabel>
<ProgressValue />
</Progress>
</CardContent>
</Card>
</aside>
</div>
)
}

View File

@@ -1,45 +0,0 @@
import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Back to articles
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
EDIT ARTICLE
</h1>
</div>
<ButtonGroup className="gap-2 md:gap-4">
<Badge title="2 minutes ago">Autosaved</Badge>
<ButtonGroup className="gap-2 md:gap-4">
<Button variant="link">
Preview
<ExternalLinkIcon data-icon="inline-end" />
</Button>
<Button>Submit Draft</Button>
</ButtonGroup>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -1,16 +0,0 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { EditorWorkspace } from "./components/editor-workspace"
import { PreviewHeader } from "./components/preview-header"
export function EditArticle() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container py-(--gap)">
<EditorWorkspace />
</div>
</div>
)
}

View File

@@ -1,95 +0,0 @@
import { FileTextIcon, PlusIcon } from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import { Button } from "@/styles/base-sera/ui/button"
import { Card, CardContent } from "@/styles/base-sera/ui/card"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/base-sera/ui/empty"
import { Separator } from "@/styles/base-sera/ui/separator"
type Stage = {
id: string
label: string
description: string
dotClassName: string
}
const STAGES: Stage[] = [
{
id: "drafting",
label: "Drafting",
description:
"Start the writing process. Articles here are works in progress, visible only to editors and authors.",
dotClassName: "bg-amber-600",
},
{
id: "in-revision",
label: "In Revision",
description:
"Content undergoing editorial review. Track changes and word counts as pieces take shape.",
dotClassName: "bg-orange-700",
},
{
id: "final-edit",
label: "Final Edit",
description:
"The final polish before publication. Ensure all styling and factual checks are complete.",
dotClassName: "bg-foreground",
},
]
export function EmptyDirectory() {
return (
<Card className="py-24">
<CardContent className="flex flex-col items-center gap-10">
<Empty className="min-h-96">
<EmptyHeader>
<EmptyMedia
variant="icon"
className="size-14 rounded-full bg-muted/70 text-muted-foreground"
>
<FileTextIcon className="size-5" />
</EmptyMedia>
<EmptyTitle className="font-heading text-2xl tracking-normal normal-case">
A Blank Canvas
</EmptyTitle>
<EmptyDescription>
Your editorial directory is currently empty. Start building your
publication&apos;s next issue by drafting the first piece.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>
<PlusIcon data-icon="inline-start" />
Create first article
</Button>
</EmptyContent>
</Empty>
<Separator className="max-w-2xl" />
<div className="grid w-full max-w-2xl grid-cols-1 gap-8 sm:grid-cols-3">
{STAGES.map((stage) => (
<div key={stage.id} className="flex flex-col gap-2">
<Badge>
<span
aria-hidden
className={`size-1.5 rounded-full ${stage.dotClassName}`}
/>
{stage.label}
</Badge>
<p className="text-xs leading-relaxed text-muted-foreground">
{stage.description}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,37 +0,0 @@
import { ArrowLeftIcon, PlusIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Editorial Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Article Directory
</h1>
</div>
<Button className="sm:ml-auto">
<PlusIcon data-icon="inline-start" />
New Article
</Button>
</div>
</header>
)
}

View File

@@ -1,16 +0,0 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { EmptyDirectory } from "./components/empty-directory"
import { PreviewHeader } from "./components/preview-header"
export function EmptyState() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container py-(--gap)">
<EmptyDirectory />
</div>
</div>
)
}

View File

@@ -1,211 +0,0 @@
"use client"
import * as React from "react"
import {
FileTextIcon,
ImageIcon,
MoreVerticalIcon,
SearchIcon,
VideoIcon,
} from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import { Button } from "@/styles/base-sera/ui/button"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-sera/ui/card"
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/styles/base-sera/ui/input-group"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/styles/base-sera/ui/pagination"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/styles/base-sera/ui/table"
import { ASSETS, type AssetType } from "../../media-library/data"
function AssetTypeIcon({
type,
className,
}: {
type: AssetType
className?: string
}) {
if (type === "MP4") {
return <VideoIcon className={className} />
}
if (type === "PDF") {
return <FileTextIcon className={className} />
}
return <ImageIcon className={className} />
}
export function AssetTable() {
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(
new Set(["1"])
)
const toggleSelection = React.useCallback((id: string) => {
setSelectedIds((previous) => {
const next = new Set(previous)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
return (
<Card>
<CardHeader>
<InputGroup className="w-full">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search files, tags, or metadata..." />
</InputGroup>
</CardHeader>
<CardContent className="px-0 py-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 pl-6" aria-label="Select" />
<TableHead className="w-20" aria-label="Preview" />
<TableHead>Filename</TableHead>
<TableHead>Type</TableHead>
<TableHead>Dimensions</TableHead>
<TableHead>Size</TableHead>
<TableHead>Uploaded By</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-10 pr-6" aria-label="Actions" />
</TableRow>
</TableHeader>
<TableBody>
{ASSETS.map((asset) => {
const isSelected = selectedIds.has(asset.id)
return (
<TableRow
key={asset.id}
data-state={isSelected ? "selected" : undefined}
className="cursor-pointer"
onClick={() => toggleSelection(asset.id)}
>
<TableCell className="pl-6">
<Checkbox
checked={isSelected}
aria-label={`Select ${asset.name}`}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() => toggleSelection(asset.id)}
/>
</TableCell>
<TableCell>
<div className="relative flex aspect-4/3 w-16 items-center justify-center bg-muted/60 ring-1 ring-border/70 ring-inset">
{asset.duration ? (
<span className="absolute right-1 bottom-1 bg-foreground/90 px-1 text-[0.5rem] font-semibold tracking-wider text-background">
{asset.duration}
</span>
) : null}
<AssetTypeIcon
type={asset.type}
className="size-4 text-muted-foreground/60"
/>
</div>
</TableCell>
<TableCell className="text-sm font-medium text-foreground">
{asset.name}
</TableCell>
<TableCell>
<Badge
variant="outline"
className="border px-2 py-0.5 text-[0.625rem]"
>
{asset.type}
</Badge>
</TableCell>
<TableCell className="text-sm">{asset.dimensions}</TableCell>
<TableCell className="text-sm">{asset.size}</TableCell>
<TableCell>{asset.uploadedBy}</TableCell>
<TableCell className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
{asset.date}
</TableCell>
<TableCell className="pr-6 text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-xs" />}
aria-label={`Open actions for ${asset.name}`}
onClick={(event) => event.stopPropagation()}
>
<MoreVerticalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Preview</DropdownMenuItem>
<DropdownMenuItem>Download</DropdownMenuItem>
<DropdownMenuItem variant="destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
<CardFooter className="justify-center py-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" text="" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
1
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">2</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">3</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="#" text="" />
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
)
}

View File

@@ -1,233 +0,0 @@
"use client"
import * as React from "react"
import { addDays, format } from "date-fns"
import { CalendarIcon, FilterIcon, XIcon } from "lucide-react"
import { type DateRange } from "react-day-picker"
import { Button } from "@/styles/base-sera/ui/button"
import { Calendar } from "@/styles/base-sera/ui/calendar"
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/styles/base-sera/ui/combobox"
import {
Field,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/styles/base-sera/ui/field"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/base-sera/ui/popover"
import { RadioGroup, RadioGroupItem } from "@/styles/base-sera/ui/radio-group"
import { Slider } from "@/styles/base-sera/ui/slider"
const FILE_TYPES = [
{
id: "images",
label: "Images (JPEG, PNG, WEBP)",
defaultChecked: true,
},
{
id: "video",
label: "Video (MP4, MOV)",
defaultChecked: false,
},
{
id: "documents",
label: "Documents (PDF)",
defaultChecked: false,
},
{
id: "audio",
label: "Audio (MP3, WAV)",
defaultChecked: false,
},
]
const DATE_OPTIONS = [
{ value: "any", label: "Any time" },
{ value: "24h", label: "Past 24 hours" },
{ value: "week", label: "Past week" },
{ value: "month", label: "Past month" },
]
const TAGS = [
"architecture",
"brutalism",
"ceramics",
"design-week",
"editorial",
"exterior",
"film",
"food",
"furniture",
"interior",
"kyoto",
"minimalism",
"print",
"sustainability",
"summer-issue",
"video",
] as const
export function FilterLibrary() {
const tagAnchor = useComboboxAnchor()
const [dateRange, setDateRange] = React.useState<DateRange | undefined>({
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
to: addDays(
new Date(new Date().getFullYear(), new Date().getMonth(), 1),
21
),
})
return (
<Card>
<CardHeader className="border-b">
<CardTitle>Filter Library</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldLegend>File Type</FieldLegend>
<Field>
{FILE_TYPES.map((type) => (
<Field key={type.id} orientation="horizontal">
<Checkbox id={type.id} defaultChecked={type.defaultChecked} />
<FieldLabel htmlFor={type.id}>{type.label}</FieldLabel>
</Field>
))}
</Field>
</FieldSet>
<FieldSet>
<FieldLegend>Date Uploaded</FieldLegend>
<RadioGroup defaultValue="any">
{DATE_OPTIONS.map((option) => (
<Field key={option.value} orientation="horizontal">
<RadioGroupItem
value={option.value}
id={`date-${option.value}`}
/>
<FieldLabel htmlFor={`date-${option.value}`}>
{option.label}
</FieldLabel>
</Field>
))}
</RadioGroup>
</FieldSet>
<Field>
<FieldLabel htmlFor="custom-range">Custom Range</FieldLabel>
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
id="custom-range"
className="w-full"
/>
}
>
<CalendarIcon data-icon="inline-start" />
{dateRange?.from ? (
dateRange.to ? (
<>
{format(dateRange.from, "LLL dd, y")} {" "}
{format(dateRange.to, "LLL dd, y")}
</>
) : (
format(dateRange.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={setDateRange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</Field>
<FieldSet>
<FieldLegend>File Size</FieldLegend>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-xs font-medium tracking-wider text-muted-foreground uppercase">
<span>0 MB</span>
<span>500+ MB</span>
</div>
<Slider defaultValue={[0, 60]} max={100} step={1} />
<div className="flex items-center justify-between text-xs font-medium">
<span>Min: 0 MB</span>
<span>Max: 300 MB</span>
</div>
</div>
</FieldSet>
<FieldSet>
<FieldLegend>Tags</FieldLegend>
<Field>
<Combobox
multiple
autoHighlight
items={TAGS}
defaultValue={["architecture", "brutalism"]}
>
<ComboboxChips ref={tagAnchor}>
<ComboboxValue>
{(values) => (
<React.Fragment>
{values.map((value: string) => (
<ComboboxChip key={value}>{value}</ComboboxChip>
))}
<ComboboxChipsInput placeholder="Filter by tag..." />
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={tagAnchor}>
<ComboboxEmpty>No tags found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
<CardFooter className="flex flex-col gap-2 border-t">
<Button className="w-full">Apply Filters</Button>
<Button variant="ghost" className="w-full">
Reset
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -1,47 +0,0 @@
import { ArrowLeftIcon, SlidersHorizontalIcon, UploadIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Asset management
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Media Library
</h1>
</div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<Button
variant="outline"
className="bg-background hover:bg-background/80"
>
<SlidersHorizontalIcon data-icon="inline-start" />
Filters
</Button>
<Button>
<UploadIcon data-icon="inline-start" />
Upload Assets
</Button>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -1,18 +0,0 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { AssetTable } from "./components/asset-table"
import { FilterLibrary } from "./components/filter-library"
import { PreviewHeader } from "./components/preview-header"
export function MediaLibraryTable() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container grid grid-cols-1 items-start gap-(--gap) py-(--gap) xl:grid-cols-[minmax(0,1fr)_320px]">
<AssetTable />
<FilterLibrary />
</div>
</div>
)
}

View File

@@ -1,110 +0,0 @@
import {
DownloadIcon,
FileTextIcon,
ImageIcon,
PlusIcon,
VideoIcon,
} from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import { Button } from "@/styles/base-sera/ui/button"
import { Card, CardContent, CardFooter } from "@/styles/base-sera/ui/card"
import {
Item,
ItemContent,
ItemDescription,
ItemTitle,
} from "@/styles/base-sera/ui/item"
import { Separator } from "@/styles/base-sera/ui/separator"
import { type Asset, type AssetType } from "../data"
const TYPE_LABEL: Record<AssetType, string> = {
JPEG: "Image / JPEG",
PNG: "Image / PNG",
WEBP: "Image / WEBP",
MP4: "Video / MP4",
PDF: "Document / PDF",
}
export function AssetDetails({ asset }: { asset: Asset }) {
return (
<Card className="gap-0">
<CardContent className="flex flex-col gap-6">
<div className="flex aspect-5/4 items-center justify-center bg-muted/60 text-muted-foreground/60 ring-1 ring-border/70 ring-inset">
{asset.type === "MP4" ? (
<VideoIcon className="size-8" />
) : asset.type === "PDF" ? (
<FileTextIcon className="size-8" />
) : (
<ImageIcon className="size-8" />
)}
</div>
<h2 className="line-clamp-2 font-heading text-xl tracking-wide">
{asset.name}
</h2>
<Separator />
<dl className="flex flex-col gap-5 text-sm">
<div className="flex flex-col gap-1.5">
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Asset Type
</dt>
<dd>{TYPE_LABEL[asset.type]}</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Dimensions
</dt>
<dd>{asset.dimensions}</dd>
</div>
<div className="flex flex-col gap-1.5">
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
File Size
</dt>
<dd>{asset.size}</dd>
</div>
</div>
</dl>
<Separator />
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Tags
</h3>
<Button variant="ghost" size="icon-xs" aria-label="Add tag">
<PlusIcon />
</Button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{asset.tags.map((tag) => (
<Badge key={tag}>{tag}</Badge>
))}
</div>
</div>
<Separator />
<div className="flex flex-col gap-3">
<h3 className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Used In
</h3>
<div className="flex flex-col gap-2">
{asset.usedIn.map((usage) => (
<Item key={usage.title} variant="outline">
<ItemContent>
<ItemTitle>{usage.title}</ItemTitle>
<ItemDescription>{usage.role}</ItemDescription>
</ItemContent>
</Item>
))}
</div>
</div>
</CardContent>
<CardFooter className="mt-6 border-t pt-6">
<Button className="w-full">
<DownloadIcon data-icon="inline-start" />
Download
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -1,160 +0,0 @@
"use client"
import {
CheckIcon,
FileTextIcon,
ImageIcon,
SearchIcon,
VideoIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-sera/ui/card"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/styles/base-sera/ui/input-group"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/styles/base-sera/ui/pagination"
import { ASSETS, type Asset, type AssetType } from "../data"
function AssetTypeIcon({
type,
className,
}: {
type: AssetType
className?: string
}) {
if (type === "MP4") {
return <VideoIcon className={className} />
}
if (type === "PDF") {
return <FileTextIcon className={className} />
}
return <ImageIcon className={className} />
}
export function AssetGrid({
selectedId,
onSelect,
}: {
selectedId: string
onSelect: (id: string) => void
}) {
return (
<Card>
<CardHeader>
<InputGroup className="w-full">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search files, tags, or metadata..." />
</InputGroup>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{ASSETS.map((asset) => (
<AssetGridItem
key={asset.id}
asset={asset}
selected={asset.id === selectedId}
onSelect={() => onSelect(asset.id)}
/>
))}
</div>
</CardContent>
<CardFooter className="justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
1
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">2</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">3</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
)
}
function AssetGridItem({
asset,
selected,
onSelect,
}: {
asset: Asset
selected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className="group flex flex-col gap-2.5 text-left outline-none focus-visible:[&>div:first-child]:ring-2 focus-visible:[&>div:first-child]:ring-ring"
>
<div
className={cn(
"relative flex aspect-4/3 items-center justify-center bg-muted/60 ring-1 ring-border/70 transition-shadow ring-inset group-hover:ring-foreground/40",
selected && "ring-2 ring-foreground group-hover:ring-foreground"
)}
>
{selected ? (
<div className="absolute top-2 left-2 flex size-5 items-center justify-center bg-foreground text-background">
<CheckIcon className="size-3" />
</div>
) : null}
<Badge
variant="outline"
className="absolute top-2 right-2 border bg-background px-2 py-1 text-[0.625rem]"
>
{asset.type}
</Badge>
{asset.duration ? (
<Badge className="absolute bottom-2 left-2 bg-foreground px-2 py-1 text-background">
{asset.duration}
</Badge>
) : null}
<AssetTypeIcon
type={asset.type}
className="size-7 text-muted-foreground/60"
/>
</div>
<div className="flex flex-col gap-0.5 px-0.5">
<p className="line-clamp-1 text-sm font-medium">{asset.name}</p>
<p className="text-[0.625rem] font-semibold tracking-wider text-muted-foreground uppercase">
{asset.date} · {asset.size}
</p>
</div>
</button>
)
}

View File

@@ -1,47 +0,0 @@
import { ArrowLeftIcon, SlidersHorizontalIcon, UploadIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Asset management
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Media Library
</h1>
</div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<Button
variant="outline"
className="bg-background hover:bg-background/80"
>
<SlidersHorizontalIcon data-icon="inline-start" />
Filters
</Button>
<Button>
<UploadIcon data-icon="inline-start" />
Upload Assets
</Button>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -1,188 +0,0 @@
export type AssetType = "JPEG" | "PNG" | "WEBP" | "MP4" | "PDF"
export type Asset = {
id: string
name: string
date: string
size: string
type: AssetType
dimensions: string
duration?: string
uploadedBy: string
uploadedByInitials: string
uploadedOn: string
tags: string[]
usedIn: { title: string; role: string }[]
}
export const ASSETS: Asset[] = [
{
id: "1",
name: "brutalism-facade-01.jpg",
date: "Oct 24",
size: "4.2 MB",
type: "JPEG",
dimensions: "4000 × 3000",
uploadedBy: "Marcus Chen",
uploadedByInitials: "MC",
uploadedOn: "Oct 24, 2024",
tags: ["architecture", "brutalism", "exterior", "summer-issue"],
usedIn: [
{ title: "Brutalism's Second Act", role: "Cover Image" },
{ title: "Autumn Sartorial Code", role: "Inline Gallery" },
],
},
{
id: "2",
name: "brutalism-interior-raw.jpg",
date: "Oct 24",
size: "3.8 MB",
type: "JPEG",
dimensions: "3800 × 2850",
uploadedBy: "Marcus Chen",
uploadedByInitials: "MC",
uploadedOn: "Oct 24, 2024",
tags: ["architecture", "brutalism", "interior"],
usedIn: [{ title: "Brutalism's Second Act", role: "Inline Gallery" }],
},
{
id: "3",
name: "seattle-living-building-diagram.png",
date: "Oct 22",
size: "1.1 MB",
type: "PNG",
dimensions: "2000 × 1500",
uploadedBy: "Sarah Jenkins",
uploadedByInitials: "SJ",
uploadedOn: "Oct 22, 2024",
tags: ["diagram", "sustainability", "seattle"],
usedIn: [
{ title: "The Future of Sustainable Architecture", role: "Diagram" },
],
},
{
id: "4",
name: "interview-sofia-coppola-clip1.mp4",
date: "Oct 18",
size: "45.0 MB",
type: "MP4",
dimensions: "1920 × 1080",
duration: "0:45",
uploadedBy: "Emma Ross",
uploadedByInitials: "ER",
uploadedOn: "Oct 18, 2024",
tags: ["video", "interview", "film"],
usedIn: [{ title: "The Aesthetics of Isolation", role: "Featured Video" }],
},
{
id: "5",
name: "kyoto-kilns-pottery-detail.jpg",
date: "Oct 15",
size: "5.6 MB",
type: "JPEG",
dimensions: "4500 × 3000",
uploadedBy: "Marcus Chen",
uploadedByInitials: "MC",
uploadedOn: "Oct 15, 2024",
tags: ["ceramics", "kyoto", "craft"],
usedIn: [{ title: "Kyoto's Oldest Kilns", role: "Hero Image" }],
},
{
id: "6",
name: "copenhagen-design-week-street.jpg",
date: "Oct 12",
size: "3.2 MB",
type: "JPEG",
dimensions: "3600 × 2400",
uploadedBy: "Noah Bennett",
uploadedByInitials: "NB",
uploadedOn: "Oct 12, 2024",
tags: ["copenhagen", "design-week", "street"],
usedIn: [{ title: "Field Notes from Copenhagen", role: "Inline Gallery" }],
},
{
id: "7",
name: "minimalist-chair-render.webp",
date: "Oct 10",
size: "0.8 MB",
type: "WEBP",
dimensions: "2400 × 1600",
uploadedBy: "Claire Duval",
uploadedByInitials: "CD",
uploadedOn: "Oct 10, 2024",
tags: ["furniture", "minimalism", "render"],
usedIn: [{ title: "The New Vanguard", role: "Product Shot" }],
},
{
id: "8",
name: "autumn-issue-style-guide.pdf",
date: "Oct 05",
size: "12.4 MB",
type: "PDF",
dimensions: "N/A",
uploadedBy: "Emma Ross",
uploadedByInitials: "ER",
uploadedOn: "Oct 05, 2024",
tags: ["guidelines", "internal", "autumn"],
usedIn: [{ title: "Autumn Issue 2024", role: "Reference" }],
},
{
id: "9",
name: "milan-lighting-studio-visit.jpg",
date: "Oct 09",
size: "6.1 MB",
type: "JPEG",
dimensions: "5200 × 3466",
uploadedBy: "Claire Duval",
uploadedByInitials: "CD",
uploadedOn: "Oct 09, 2024",
tags: ["milan", "lighting", "studio"],
usedIn: [
{ title: "Milan's Most Elusive Lighting Designer", role: "Hero Image" },
],
},
{
id: "10",
name: "lisbon-culinary-scene-raw.webp",
date: "Oct 14",
size: "2.4 MB",
type: "WEBP",
dimensions: "3000 × 2000",
uploadedBy: "Amara Iqbal",
uploadedByInitials: "AI",
uploadedOn: "Oct 14, 2024",
tags: ["lisbon", "food", "editorial"],
usedIn: [
{ title: "Lisbon's Quiet Culinary Renaissance", role: "Inline Gallery" },
],
},
{
id: "11",
name: "print-magazine-covers-mo...",
date: "Sep 26",
size: "8.9 MB",
type: "PNG",
dimensions: "3200 × 2400",
uploadedBy: "Mina Okafor",
uploadedByInitials: "MO",
uploadedOn: "Sep 26, 2024",
tags: ["print", "magazine", "covers"],
usedIn: [{ title: "The Return of Print", role: "Cover Image" }],
},
{
id: "12",
name: "avant-garde-furniture-trailer.mp4",
date: "Sep 30",
size: "78.2 MB",
type: "MP4",
dimensions: "3840 × 2160",
duration: "1:12",
uploadedBy: "Tommy Rhodes",
uploadedByInitials: "TR",
uploadedOn: "Sep 30, 2024",
tags: ["video", "furniture", "trailer"],
usedIn: [
{ title: "Collecting the New Avant-Garde", role: "Featured Video" },
],
},
]

View File

@@ -1,30 +0,0 @@
"use client"
import * as React from "react"
import { Separator } from "@/styles/base-sera/ui/separator"
import { AssetDetails } from "./components/asset-details"
import { AssetGrid } from "./components/asset-grid"
import { PreviewHeader } from "./components/preview-header"
import { ASSETS } from "./data"
export function MediaLibrary() {
const [selectedId, setSelectedId] = React.useState<string>(ASSETS[0].id)
const selectedAsset = React.useMemo(
() => ASSETS.find((asset) => asset.id === selectedId) ?? ASSETS[0],
[selectedId]
)
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container grid grid-cols-1 items-start gap-(--gap) py-(--gap) xl:grid-cols-[minmax(0,1fr)_320px]">
<AssetGrid selectedId={selectedId} onSelect={setSelectedId} />
<AssetDetails asset={selectedAsset} />
</div>
</div>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1,72 +0,0 @@
import { type Metadata } from "next"
import Link from "next/link"
import {
PageActions,
PageHeader,
PageHeaderDescription,
PageHeaderHeading,
} from "@/components/page-header"
import { Button } from "@/styles/radix-sera/ui/button"
import { AudienceAnalytics } from "./audience-analytics"
import { LazyPreview } from "./components/lazy-preview"
import "./style.css"
import { ArrowRightIcon } from "lucide-react"
import { ImagePreview } from "./components/image-preview"
const title = "Introducing Sera"
const description =
"Minimal. Editorial. Typographic. Underline Controls and Uppercase Headings. Shaped by Print Design Principles."
export const metadata: Metadata = {
title,
description,
openGraph: {
title,
description,
},
twitter: {
card: "summary_large_image",
title,
description,
},
}
export default function SeraPage() {
return (
<>
<PageHeader>
<PageHeaderHeading className="font-(family-name:--font-playfair-display) text-[2.875rem] tracking-tight!">
{title}
</PageHeaderHeading>
<PageHeaderDescription className="max-w-2xl text-pretty md:text-balance">
{description}
</PageHeaderDescription>
<PageActions className="**:[.container]:justify-start">
<Button asChild size="sm">
<Link href="/create?preset=b4xFeBLg4O">
Open in shadcn/create
<ArrowRightIcon data-icon="inline-end" />
</Link>
</Button>
</PageActions>
</PageHeader>
<ImagePreview />
<div className="container-wrapper hidden flex-1 flex-col section-soft px-0 md:flex md:px-2 md:py-12">
<div className="container flex flex-1 flex-col gap-10 px-0 3xl:max-w-[2000px] md:px-6">
<AudienceAnalytics />
<LazyPreview name="articleDirectory" />
<LazyPreview name="emptyState" />
<LazyPreview name="editArticle" />
<LazyPreview name="mediaLibrary" />
<LazyPreview name="mediaLibraryTable" />
</div>
</div>
{/* <ThemeSwitcher /> */}
</>
)
}

View File

@@ -1,495 +0,0 @@
@layer base {
.preview {
--font-sans: var(--font-noto-sans);
--font-heading: var(--font-playfair-display);
contain-intrinsic-size: auto 900px;
content-visibility: auto;
}
.theme-taupe {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.3);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.3);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.3);
--primary: oklch(0.214 0.009 43.1);
--primary-foreground: oklch(0.986 0.002 67.8);
--secondary: oklch(0.96 0.002 17.2);
--secondary-foreground: oklch(0.214 0.009 43.1);
--muted: oklch(0.96 0.002 17.2);
--muted-foreground: oklch(0.547 0.021 43.1);
--accent: oklch(0.96 0.002 17.2);
--accent-foreground: oklch(0.214 0.009 43.1);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.005 34.3);
--input: oklch(0.922 0.005 34.3);
--ring: oklch(0.714 0.014 41.2);
--chart-1: oklch(0.868 0.007 39.5);
--chart-2: oklch(0.547 0.021 43.1);
--chart-3: oklch(0.438 0.017 39.3);
--chart-4: oklch(0.367 0.016 35.7);
--chart-5: oklch(0.268 0.011 36.5);
--sidebar: oklch(0.986 0.002 67.8);
--sidebar-foreground: oklch(0.147 0.004 49.3);
--sidebar-primary: oklch(0.214 0.009 43.1);
--sidebar-primary-foreground: oklch(0.986 0.002 67.8);
--sidebar-accent: oklch(0.96 0.002 17.2);
--sidebar-accent-foreground: oklch(0.214 0.009 43.1);
--sidebar-border: oklch(0.922 0.005 34.3);
--sidebar-ring: oklch(0.714 0.014 41.2);
.dark & {
--background: oklch(0.147 0.004 49.3);
--foreground: oklch(0.986 0.002 67.8);
--card: oklch(0.214 0.009 43.1);
--card-foreground: oklch(0.986 0.002 67.8);
--popover: oklch(0.214 0.009 43.1);
--popover-foreground: oklch(0.986 0.002 67.8);
--primary: oklch(0.922 0.005 34.3);
--primary-foreground: oklch(0.214 0.009 43.1);
--secondary: oklch(0.268 0.011 36.5);
--secondary-foreground: oklch(0.986 0.002 67.8);
--muted: oklch(0.268 0.011 36.5);
--muted-foreground: oklch(0.714 0.014 41.2);
--accent: oklch(0.268 0.011 36.5);
--accent-foreground: oklch(0.986 0.002 67.8);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.547 0.021 43.1);
--chart-1: oklch(0.868 0.007 39.5);
--chart-2: oklch(0.547 0.021 43.1);
--chart-3: oklch(0.438 0.017 39.3);
--chart-4: oklch(0.367 0.016 35.7);
--chart-5: oklch(0.268 0.011 36.5);
--sidebar: oklch(0.214 0.009 43.1);
--sidebar-foreground: oklch(0.986 0.002 67.8);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.986 0.002 67.8);
--sidebar-accent: oklch(0.268 0.011 36.5);
--sidebar-accent-foreground: oklch(0.986 0.002 67.8);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.547 0.021 43.1);
}
}
.theme-neutral {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
.dark & {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
}
.theme-stone {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.869 0.005 56.366);
--chart-2: oklch(0.553 0.013 58.071);
--chart-3: oklch(0.444 0.011 73.639);
--chart-4: oklch(0.374 0.01 67.558);
--chart-5: oklch(0.268 0.007 34.298);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
.dark & {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.923 0.003 48.717);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.869 0.005 56.366);
--chart-2: oklch(0.553 0.013 58.071);
--chart-3: oklch(0.444 0.011 73.639);
--chart-4: oklch(0.374 0.01 67.558);
--chart-5: oklch(0.268 0.007 34.298);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
}
}
.theme-zinc {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
.dark & {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
}
.theme-mauve {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0.008 326);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0.008 326);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0.008 326);
--primary: oklch(0.212 0.019 322.12);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.96 0.003 325.6);
--secondary-foreground: oklch(0.212 0.019 322.12);
--muted: oklch(0.96 0.003 325.6);
--muted-foreground: oklch(0.542 0.034 322.5);
--accent: oklch(0.96 0.003 325.6);
--accent-foreground: oklch(0.212 0.019 322.12);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.005 325.62);
--input: oklch(0.922 0.005 325.62);
--ring: oklch(0.711 0.019 323.02);
--chart-1: oklch(0.865 0.012 325.68);
--chart-2: oklch(0.542 0.034 322.5);
--chart-3: oklch(0.435 0.029 321.78);
--chart-4: oklch(0.364 0.029 323.89);
--chart-5: oklch(0.263 0.024 320.12);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0.008 326);
--sidebar-primary: oklch(0.212 0.019 322.12);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.96 0.003 325.6);
--sidebar-accent-foreground: oklch(0.212 0.019 322.12);
--sidebar-border: oklch(0.922 0.005 325.62);
--sidebar-ring: oklch(0.711 0.019 323.02);
.dark & {
--background: oklch(0.145 0.008 326);
--foreground: oklch(0.985 0 0);
--card: oklch(0.212 0.019 322.12);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.212 0.019 322.12);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0.005 325.62);
--primary-foreground: oklch(0.212 0.019 322.12);
--secondary: oklch(0.263 0.024 320.12);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.263 0.024 320.12);
--muted-foreground: oklch(0.711 0.019 323.02);
--accent: oklch(0.263 0.024 320.12);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.542 0.034 322.5);
--chart-1: oklch(0.865 0.012 325.68);
--chart-2: oklch(0.542 0.034 322.5);
--chart-3: oklch(0.435 0.029 321.78);
--chart-4: oklch(0.364 0.029 323.89);
--chart-5: oklch(0.263 0.024 320.12);
--sidebar: oklch(0.212 0.019 322.12);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.263 0.024 320.12);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.542 0.034 322.5);
}
}
.theme-olive {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.153 0.006 107.1);
--card: oklch(1 0 0);
--card-foreground: oklch(0.153 0.006 107.1);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.153 0.006 107.1);
--primary: oklch(0.228 0.013 107.4);
--primary-foreground: oklch(0.988 0.003 106.5);
--secondary: oklch(0.966 0.005 106.5);
--secondary-foreground: oklch(0.228 0.013 107.4);
--muted: oklch(0.966 0.005 106.5);
--muted-foreground: oklch(0.58 0.031 107.3);
--accent: oklch(0.966 0.005 106.5);
--accent-foreground: oklch(0.228 0.013 107.4);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.93 0.007 106.5);
--input: oklch(0.93 0.007 106.5);
--ring: oklch(0.737 0.021 106.9);
--chart-1: oklch(0.88 0.011 106.6);
--chart-2: oklch(0.58 0.031 107.3);
--chart-3: oklch(0.466 0.025 107.3);
--chart-4: oklch(0.394 0.023 107.4);
--chart-5: oklch(0.286 0.016 107.4);
--sidebar: oklch(0.988 0.003 106.5);
--sidebar-foreground: oklch(0.153 0.006 107.1);
--sidebar-primary: oklch(0.228 0.013 107.4);
--sidebar-primary-foreground: oklch(0.988 0.003 106.5);
--sidebar-accent: oklch(0.966 0.005 106.5);
--sidebar-accent-foreground: oklch(0.228 0.013 107.4);
--sidebar-border: oklch(0.93 0.007 106.5);
--sidebar-ring: oklch(0.737 0.021 106.9);
.dark & {
--background: oklch(0.153 0.006 107.1);
--foreground: oklch(0.988 0.003 106.5);
--card: oklch(0.228 0.013 107.4);
--card-foreground: oklch(0.988 0.003 106.5);
--popover: oklch(0.228 0.013 107.4);
--popover-foreground: oklch(0.988 0.003 106.5);
--primary: oklch(0.93 0.007 106.5);
--primary-foreground: oklch(0.228 0.013 107.4);
--secondary: oklch(0.286 0.016 107.4);
--secondary-foreground: oklch(0.988 0.003 106.5);
--muted: oklch(0.286 0.016 107.4);
--muted-foreground: oklch(0.737 0.021 106.9);
--accent: oklch(0.286 0.016 107.4);
--accent-foreground: oklch(0.988 0.003 106.5);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.58 0.031 107.3);
--chart-1: oklch(0.88 0.011 106.6);
--chart-2: oklch(0.58 0.031 107.3);
--chart-3: oklch(0.466 0.025 107.3);
--chart-4: oklch(0.394 0.023 107.4);
--chart-5: oklch(0.286 0.016 107.4);
--sidebar: oklch(0.228 0.013 107.4);
--sidebar-foreground: oklch(0.988 0.003 106.5);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.988 0.003 106.5);
--sidebar-accent: oklch(0.286 0.016 107.4);
--sidebar-accent-foreground: oklch(0.988 0.003 106.5);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.58 0.031 107.3);
}
}
.theme-mist {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.148 0.004 228.8);
--card: oklch(1 0 0);
--card-foreground: oklch(0.148 0.004 228.8);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.148 0.004 228.8);
--primary: oklch(0.218 0.008 223.9);
--primary-foreground: oklch(0.987 0.002 197.1);
--secondary: oklch(0.963 0.002 197.1);
--secondary-foreground: oklch(0.218 0.008 223.9);
--muted: oklch(0.963 0.002 197.1);
--muted-foreground: oklch(0.56 0.021 213.5);
--accent: oklch(0.963 0.002 197.1);
--accent-foreground: oklch(0.218 0.008 223.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.005 214.3);
--input: oklch(0.925 0.005 214.3);
--ring: oklch(0.723 0.014 214.4);
--chart-1: oklch(0.872 0.007 219.6);
--chart-2: oklch(0.56 0.021 213.5);
--chart-3: oklch(0.45 0.017 213.2);
--chart-4: oklch(0.378 0.015 216);
--chart-5: oklch(0.275 0.011 216.9);
--sidebar: oklch(0.987 0.002 197.1);
--sidebar-foreground: oklch(0.148 0.004 228.8);
--sidebar-primary: oklch(0.218 0.008 223.9);
--sidebar-primary-foreground: oklch(0.987 0.002 197.1);
--sidebar-accent: oklch(0.963 0.002 197.1);
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
--sidebar-border: oklch(0.925 0.005 214.3);
--sidebar-ring: oklch(0.723 0.014 214.4);
.dark & {
--background: oklch(0.148 0.004 228.8);
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.925 0.005 214.3);
--primary-foreground: oklch(0.218 0.008 223.9);
--secondary: oklch(0.275 0.011 216.9);
--secondary-foreground: oklch(0.987 0.002 197.1);
--muted: oklch(0.275 0.011 216.9);
--muted-foreground: oklch(0.723 0.014 214.4);
--accent: oklch(0.275 0.011 216.9);
--accent-foreground: oklch(0.987 0.002 197.1);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.56 0.021 213.5);
--chart-1: oklch(0.872 0.007 219.6);
--chart-2: oklch(0.56 0.021 213.5);
--chart-3: oklch(0.45 0.017 213.2);
--chart-4: oklch(0.378 0.015 216);
--chart-5: oklch(0.275 0.011 216.9);
--sidebar: oklch(0.218 0.008 223.9);
--sidebar-foreground: oklch(0.987 0.002 197.1);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.987 0.002 197.1);
--sidebar-accent: oklch(0.275 0.011 216.9);
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.56 0.021 213.5);
}
}
}
@utility font-heading {
font-family: var(--font-serif);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -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>

View File

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

View File

@@ -1,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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

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

View File

@@ -1,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>
)
}

View File

@@ -1,405 +0,0 @@
"use client"
import * as React from "react"
import {
Copy01Icon,
Globe02Icon,
HandPointingRight04Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/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 pointerFlag = params.pointer ? " --pointer" : ""
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}${pointerFlag}`
return IS_LOCAL_DEV
? {
pnpm: `shadcn init${flags}`,
npm: `shadcn init${flags}`,
yarn: `shadcn init${flags}`,
bun: `shadcn init${flags}`,
}
: {
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} init${flags}`,
npm: `npx shadcn${SHADCN_VERSION} init${flags}`,
yarn: `yarn dlx shadcn${SHADCN_VERSION} init${flags}`,
bun: `bunx --bun shadcn${SHADCN_VERSION} init${flags}`,
}
}, [
framework,
isMonorepo,
params.base,
params.pointer,
params.rtl,
presetCode,
])
const command = commands[packageManager]
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">
<FieldLabel htmlFor="pointer">
<HugeiconsIcon
icon={HandPointingRight04Icon}
className="size-4 -rotate-90"
/>
Use pointer on buttons
</FieldLabel>
<Switch
id="pointer"
checked={params.pointer}
onCheckedChange={(checked) =>
setParams({ pointer: checked === true })
}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field
orientation="horizontal"
data-disabled={hasMonorepo ? undefined : "true"}
>
<FieldLabel htmlFor="monorepo">
<span
className="size-4 text-neutral-100 [&_svg]:size-4 [&_svg]:fill-current"
dangerouslySetInnerHTML={{
__html: TURBOREPO_LOGO,
}}
/>
Create a monorepo
</FieldLabel>
<Switch
id="monorepo"
checked={params.template?.endsWith("-monorepo") ?? false}
disabled={!hasMonorepo}
onCheckedChange={(checked) => {
const framework = getFramework(params.template ?? "next")
setParams({
template: getTemplateValue(
framework,
checked === true
) as typeof params.template,
})
}}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field orientation="horizontal">
<FieldLabel htmlFor="rtl">
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
Enable RTL support
</FieldLabel>
<Switch
id="rtl"
checked={params.rtl}
onCheckedChange={(checked) =>
setParams({ rtl: checked === true })
}
/>
</Field>
</FieldSet>
</FieldGroup>
</div>
<DialogFooter className="-mx-6 -mb-6 min-w-0">
<div className="flex w-full min-w-0 flex-col gap-3">
<Tabs
value={packageManager}
onValueChange={(value) => {
setConfig((prev) => ({
...prev,
packageManager: value as PackageManager,
}))
}}
className="min-w-0 gap-0 overflow-hidden rounded-xl border-0 ring-1 ring-border"
>
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
<TabsList className="bg-transparent font-mono">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger
key={manager}
value={manager}
className="py-0 leading-none data-[state=active]:shadow-none"
>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
>
{hasCopied ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(commands).map(([key, cmd]) => {
return (
<TabsContent key={key} value={key}>
<div className="relative overflow-hidden border-t bg-popover p-3">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
</div>
</div>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopy} className="h-9 w-full">
{hasCopied ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
const TemplateGrid = React.memo(function TemplateGrid({
template,
setParams,
}: {
template: DesignSystemSearchParams["template"]
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
}) {
const isMonorepo = template?.endsWith("-monorepo") ?? false
const framework = getFramework(template ?? "next")
const handleTemplateChange = React.useCallback(
(value: string) => {
setParams({
template: getTemplateValue(
value,
isMonorepo
) as DesignSystemSearchParams["template"],
})
},
[isMonorepo, setParams]
)
return (
<RadioGroup
value={framework}
onValueChange={handleTemplateChange}
className="grid grid-cols-2 gap-2"
>
{TEMPLATES.map((item) => (
<FieldLabel
key={item.value}
htmlFor={`template-${item.value}`}
className="block w-full"
>
<Field
orientation="horizontal"
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
>
<FieldContent className="flex flex-row items-center gap-2 px-2.5 py-1.5">
<div
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
dangerouslySetInnerHTML={{
__html: item.logo,
}}
></div>
<FieldTitle>{item.title}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={item.value}
id={`template-${item.value}`}
className="sr-only absolute"
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})
const BaseGrid = React.memo(function BaseGrid({
base,
setParams,
}: {
base: DesignSystemSearchParams["base"]
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
}) {
const handleBaseChange = React.useCallback(
(value: string) => {
setParams({ base: value as BaseName })
},
[setParams]
)
return (
<RadioGroup
value={base}
onValueChange={handleBaseChange}
aria-label="Base"
className="grid grid-cols-2 gap-2"
>
{BASES.map((item) => (
<FieldLabel
key={item.name}
htmlFor={`base-${item.name}`}
className="block w-full"
>
<Field
orientation="horizontal"
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
>
<FieldContent className="flex flex-row items-center gap-2 px-2.5 py-1.5">
<div
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
dangerouslySetInnerHTML={{
__html: item.meta?.logo ?? "",
}}
/>
<FieldTitle>{item.title}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={item.name}
id={`base-${item.name}`}
className="sr-only absolute"
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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,
}
}

View File

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

View File

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

View File

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

View File

@@ -1,250 +0,0 @@
import {
DM_Sans,
EB_Garamond,
Figtree,
Geist,
Geist_Mono,
IBM_Plex_Sans,
Instrument_Sans,
Instrument_Serif,
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 ebGaramond = EB_Garamond({
subsets: ["latin"],
variable: "--font-eb-garamond",
})
const instrumentSerif = Instrument_Serif({
subsets: ["latin"],
weight: "400",
variable: "--font-instrument-serif",
})
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,
"eb-garamond": ebGaramond,
"instrument-serif": instrumentSerif,
} 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"),
createFontOption("eb-garamond"),
createFontOption("instrument-serif"),
] as const
export type Font = (typeof FONTS)[number]
export const FONT_HEADING_OPTIONS = [
{
name: "Inherit",
value: "inherit",
font: null,
type: "default",
},
...FONTS,
] as const
export type FontHeadingOption = (typeof FONT_HEADING_OPTIONS)[number]

View File

@@ -1,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()
})
})

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,312 +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),
pointer: 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",
"pointer",
"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,
pointer: rawParams.pointer,
size: rawParams.size,
custom: rawParams.custom,
})
}
}
return normalizeDesignSystemParams(rawParams)
}
// Wraps nuqs useQueryStates with transparent preset encoding/decoding.
// - Reads: if ?preset=CODE is in the URL, decodes it and returns individual values.
// - Writes: when design system params are set, encodes them into a preset code.
export function useDesignSystemSearchParams(options: Options = {}) {
const searchParams = useSearchParams()
const [rawParams, rawSetParams] = useQueryStates(designSystemSearchParams, {
shallow: false,
history: "push",
...options,
})
const params = React.useMemo(
() => resolvePresetParams(rawParams, searchParams),
[rawParams, searchParams]
)
// Use ref so setParams callback stays stable across renders.
const paramsRef = React.useRef(params)
React.useEffect(() => {
paramsRef.current = params
}, [params])
type RawSetParamsInput = Parameters<typeof rawSetParams>[0]
const setParams = React.useCallback(
(
updates:
| Partial<DesignSystemSearchParams>
| ((
old: DesignSystemSearchParams
) => Partial<DesignSystemSearchParams>),
setOptions?: Options
) => {
const resolvedUpdates = normalizePartialDesignSystemParams(
typeof updates === "function" ? updates(paramsRef.current) : updates
)
const hasDesignSystemUpdate = DESIGN_SYSTEM_KEYS.some(
(key) => key in resolvedUpdates
)
if (!hasDesignSystemUpdate) {
// No design system change, pass through directly.
return rawSetParams(resolvedUpdates as RawSetParamsInput, setOptions)
}
// Merge current decoded values with updates.
const merged = normalizeDesignSystemParams({
...paramsRef.current,
...resolvedUpdates,
})
// Encode design system fields into a preset code.
// Cast needed: merged values may include null from nuqs resets,
// but encodePreset handles missing values by falling back to defaults.
const code = getPresetCode(merged)
// Build update: set preset, clear individual DS params from URL.
const rawUpdate: Record<string, unknown> = { preset: code }
for (const key of DESIGN_SYSTEM_KEYS) {
rawUpdate[key] = null
}
// Pass through non-DS params that were explicitly in the update.
for (const key of NON_DESIGN_SYSTEM_KEYS) {
if (key in resolvedUpdates) {
rawUpdate[key] = (resolvedUpdates as Record<string, unknown>)[key]
}
}
return rawSetParams(rawUpdate as RawSetParamsInput, setOptions)
},
[rawSetParams]
)
return [params, setParams] as const
}

View File

@@ -1,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")
})
})

View File

@@ -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} />
}

View File

@@ -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

View File

@@ -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 = 5
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">

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

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