mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-07 22:18:39 +00:00
Compare commits
1 Commits
changeset-
...
shadcn/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86479e0a30 |
@@ -9,6 +9,5 @@
|
|||||||
"WebFetch(domain:github.com)"
|
"WebFetch(domain:github.com)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
},
|
}
|
||||||
"outputStyle": "Explanatory"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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).
|
|
||||||
35
.github/collect-prerelease-info.js
vendored
35
.github/collect-prerelease-info.js
vendored
@@ -1,35 +0,0 @@
|
|||||||
// Collect the packages that a snapshot prerelease just published, so the
|
|
||||||
// prerelease comment workflow can render an install line per package.
|
|
||||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
|
||||||
import { join } from "node:path"
|
|
||||||
|
|
||||||
const [, , prNumber, channel] = process.argv
|
|
||||||
|
|
||||||
const packagesDir = join(process.cwd(), "packages")
|
|
||||||
const published = []
|
|
||||||
|
|
||||||
for (const dir of readdirSync(packagesDir)) {
|
|
||||||
const pkgPath = join(packagesDir, dir, "package.json")
|
|
||||||
if (!existsSync(pkgPath)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
|
|
||||||
|
|
||||||
// Snapshot versions are stamped `0.0.0-<channel>-<timestamp>`, so the channel
|
|
||||||
// marker is how we tell which packages this run actually versioned.
|
|
||||||
if (
|
|
||||||
!pkg.private &&
|
|
||||||
typeof pkg.version === "string" &&
|
|
||||||
pkg.version.includes(`-${channel}`)
|
|
||||||
) {
|
|
||||||
published.push({ name: pkg.name, version: pkg.version })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
"prerelease-info.json",
|
|
||||||
JSON.stringify({ pr: prNumber, channel, packages: published }, null, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log(`Collected ${published.length} prerelease package(s).`)
|
|
||||||
21
.github/version-script-beta.js
vendored
Normal file
21
.github/version-script-beta.js
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// ORIGINALLY FROM CLOUDFLARE WRANGLER:
|
||||||
|
// https://github.com/cloudflare/wrangler2/blob/main/.github/version-script.js
|
||||||
|
|
||||||
|
import { exec } from "child_process"
|
||||||
|
import fs from "fs"
|
||||||
|
|
||||||
|
const pkgJsonPath = "packages/shadcn/package.json"
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
|
||||||
|
exec("git rev-parse --short HEAD", (err, stdout) => {
|
||||||
|
if (err) {
|
||||||
|
console.log(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
pkg.version = "0.0.0-beta." + stdout.trim()
|
||||||
|
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, "\t") + "\n")
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
56
.github/workflows/browser-tests.yml
vendored
56
.github/workflows/browser-tests.yml
vendored
@@ -1,56 +0,0 @@
|
|||||||
name: Browser tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: ["*"]
|
|
||||||
paths:
|
|
||||||
- "packages/react/**"
|
|
||||||
- ".github/workflows/browser-tests.yml"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
browser:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: pnpm test:browser
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
name: Install pnpm
|
|
||||||
id: pnpm-install
|
|
||||||
with:
|
|
||||||
version: 10.33.4
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
id: pnpm-cache
|
|
||||||
run: |
|
|
||||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
|
||||||
- uses: actions/cache@v3
|
|
||||||
name: Setup pnpm cache
|
|
||||||
with:
|
|
||||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Cache Playwright browsers
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ~/.cache/ms-playwright
|
|
||||||
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-playwright-
|
|
||||||
|
|
||||||
- name: Install Playwright Chromium
|
|
||||||
run: pnpm --filter=@shadcn/react exec playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- run: pnpm --filter=@shadcn/react test:browser
|
|
||||||
10
.github/workflows/code-check.yml
vendored
10
.github/workflows/code-check.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
id: pnpm-install
|
id: pnpm-install
|
||||||
with:
|
with:
|
||||||
version: 10.33.4
|
version: 9.0.6
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
id: pnpm-install
|
id: pnpm-install
|
||||||
with:
|
with:
|
||||||
version: 10.33.4
|
version: 9.0.6
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: pnpm build:packages
|
run: pnpm --filter=shadcn build
|
||||||
|
|
||||||
- run: pnpm format:check
|
- run: pnpm format:check
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ jobs:
|
|||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
id: pnpm-install
|
id: pnpm-install
|
||||||
with:
|
with:
|
||||||
version: 10.33.4
|
version: 9.0.6
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
@@ -117,6 +117,6 @@ jobs:
|
|||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: pnpm build:packages
|
run: pnpm --filter=shadcn build
|
||||||
|
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
|
|||||||
78
.github/workflows/deprecated.yml
vendored
Normal file
78
.github/workflows/deprecated.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: Deprecated
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deprecated:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout PR
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v46
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
apps/www/**
|
||||||
|
files_ignore: |
|
||||||
|
apps/www/public/r/**
|
||||||
|
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||||
|
sha: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
- name: Comment on PR if www files changed
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const changedFiles = `${{ steps.changed-files.outputs.all_changed_files }}`.split(' ');
|
||||||
|
const wwwFiles = changedFiles.filter(file =>
|
||||||
|
file.startsWith('apps/www/') &&
|
||||||
|
!file.startsWith('apps/www/public/r/') &&
|
||||||
|
file !== 'apps/www/package.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wwwFiles.length > 0) {
|
||||||
|
const comment = `Looks like this PR modifies files in \`apps/www\`, which is deprecated.
|
||||||
|
|
||||||
|
Consider applying the change to \`apps/v4\` if relevant.`;
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: comment
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add deprecated label
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
labels: ['deprecated']
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Remove deprecated label if no www files are changed
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
name: 'deprecated'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Label doesn't exist, which is fine
|
||||||
|
console.log('Deprecated label not found, skipping removal');
|
||||||
|
}
|
||||||
|
}
|
||||||
107
.github/workflows/prerelease-comment.yml
vendored
107
.github/workflows/prerelease-comment.yml
vendored
@@ -1,9 +1,9 @@
|
|||||||
# Adapted from create-t3-app.
|
# Adapted from create-t3-app.
|
||||||
name: Write Prerelease comment
|
name: Write Beta Release comment
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Release"]
|
workflows: ["Release - Beta"]
|
||||||
types:
|
types:
|
||||||
- completed
|
- completed
|
||||||
|
|
||||||
@@ -11,84 +11,55 @@ jobs:
|
|||||||
comment:
|
comment:
|
||||||
if: |
|
if: |
|
||||||
github.repository_owner == 'shadcn-ui' &&
|
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
|
runs-on: ubuntu-latest
|
||||||
name: Write comment to the PR
|
name: Write comment to the PR
|
||||||
steps:
|
steps:
|
||||||
# Stable pushes and no-changeset runs upload no artifact, so a missing
|
- name: "Comment on PR"
|
||||||
# download is expected — gate the rest of the job on it succeeding.
|
uses: actions/github-script@v6
|
||||||
- name: Download prerelease info
|
|
||||||
id: download
|
|
||||||
continue-on-error: true
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: prerelease-info
|
|
||||||
run-id: ${{ github.event.workflow_run.id }}
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build comment
|
|
||||||
id: info
|
|
||||||
if: steps.download.outcome == 'success'
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
script: |
|
||||||
const fs = require("fs");
|
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||||
const info = JSON.parse(fs.readFileSync("prerelease-info.json", "utf8"));
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: context.payload.workflow_run.id,
|
||||||
|
});
|
||||||
|
|
||||||
if (!info.packages || info.packages.length === 0) {
|
for (const artifact of allArtifacts.data.artifacts) {
|
||||||
core.info("No prerelease packages to comment.");
|
// Extract the PR number and package version from the artifact name
|
||||||
return;
|
const match = /^npm-package-shadcn@(.*?)-pr-(\d+)/.exec(artifact.name);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
require("fs").appendFileSync(
|
||||||
|
process.env.GITHUB_ENV,
|
||||||
|
`\nBETA_PACKAGE_VERSION=${match[1]}` +
|
||||||
|
`\nWORKFLOW_RUN_PR=${match[2]}` +
|
||||||
|
`\nWORKFLOW_RUN_ID=${context.payload.workflow_run.id}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const installs = info.packages
|
- name: "Comment on PR with Link"
|
||||||
.map((p) => `pnpm dlx ${p.name}@${p.version}`)
|
|
||||||
.join("\n");
|
|
||||||
const links = info.packages
|
|
||||||
.map(
|
|
||||||
(p) =>
|
|
||||||
`- [${p.name}@${p.version}](https://www.npmjs.com/package/${p.name}/v/${p.version})`
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const body = [
|
|
||||||
`A new ${info.channel} prerelease is available for testing:`,
|
|
||||||
"",
|
|
||||||
"```sh",
|
|
||||||
installs,
|
|
||||||
"```",
|
|
||||||
"",
|
|
||||||
links,
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
core.setOutput("pr", info.pr);
|
|
||||||
core.setOutput("channel", info.channel);
|
|
||||||
core.setOutput("body", body);
|
|
||||||
|
|
||||||
- name: Comment on PR
|
|
||||||
if: steps.info.outputs.body
|
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
with:
|
with:
|
||||||
number: ${{ steps.info.outputs.pr }}
|
number: ${{ env.WORKFLOW_RUN_PR }}
|
||||||
message: ${{ steps.info.outputs.body }}
|
message: |
|
||||||
|
A new prerelease is available for testing:
|
||||||
|
|
||||||
- name: Remove the prerelease label once published
|
```sh
|
||||||
if: steps.info.outputs.pr
|
pnpm dlx shadcn@${{ env.BETA_PACKAGE_VERSION }}
|
||||||
uses: actions/github-script@v7
|
```
|
||||||
|
|
||||||
|
- name: "Remove the autorelease label once published"
|
||||||
|
uses: actions/github-script@v6
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
try {
|
github.rest.issues.removeLabel({
|
||||||
await github.rest.issues.removeLabel({
|
owner: context.repo.owner,
|
||||||
owner: context.repo.owner,
|
repo: context.repo.repo,
|
||||||
repo: context.repo.repo,
|
issue_number: '${{ env.WORKFLOW_RUN_PR }}',
|
||||||
issue_number: Number("${{ steps.info.outputs.pr }}"),
|
name: '🚀 autorelease',
|
||||||
name: `release: ${{ steps.info.outputs.channel }}`,
|
});
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status !== 404) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.info("The prerelease label was already removed.");
|
|
||||||
}
|
|
||||||
|
|||||||
64
.github/workflows/prerelease.yml
vendored
Normal file
64
.github/workflows/prerelease.yml
vendored
Normal 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
|
||||||
143
.github/workflows/release.yml
vendored
143
.github/workflows/release.yml
vendored
@@ -2,140 +2,31 @@
|
|||||||
|
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
run-name: ${{ github.event_name == 'pull_request' && format('Release Prerelease - PR {0}', github.event.number) || 'Release Stable' }}
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
types: [labeled]
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prerelease:
|
|
||||||
if: "${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && (contains(github.event.pull_request.labels.*.name, 'release: beta') || contains(github.event.pull_request.labels.*.name, 'release: rc')) }}"
|
|
||||||
name: Publish Prerelease to NPM
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: Preview
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Select prerelease channel
|
|
||||||
id: prerelease
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const prereleaseLabels = [
|
|
||||||
{ name: "release: beta", channel: "beta" },
|
|
||||||
{ name: "release: rc", channel: "rc" },
|
|
||||||
];
|
|
||||||
const labels = context.payload.pull_request.labels.map((label) => label.name);
|
|
||||||
const selectedLabels = prereleaseLabels.filter((label) =>
|
|
||||||
labels.includes(label.name)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedLabels.length !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
`Expected exactly one prerelease label, found: ${
|
|
||||||
selectedLabels.map((label) => label.name).join(", ") || "none"
|
|
||||||
}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setOutput("channel", selectedLabels[0].channel);
|
|
||||||
core.setOutput("label", selectedLabels[0].name);
|
|
||||||
|
|
||||||
- name: Checkout Repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
|
|
||||||
- name: Use PNPM
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10.33.4
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
# A snapshot prerelease needs changesets to compute versions. The
|
|
||||||
# Changesets version PR consumes them, so a label on that PR is a no-op.
|
|
||||||
- name: Check for changesets
|
|
||||||
id: changesets
|
|
||||||
run: |
|
|
||||||
shopt -s nullglob
|
|
||||||
present=false
|
|
||||||
for file in .changeset/*.md; do
|
|
||||||
if [ "$(basename "$file")" != "README.md" ]; then
|
|
||||||
present=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "present=$present" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: No changesets to prerelease
|
|
||||||
if: steps.changesets.outputs.present == 'false'
|
|
||||||
run: echo "::notice::No changesets found on this branch; nothing to prerelease."
|
|
||||||
|
|
||||||
# Snapshot versions are stamped per run (timestamped), so each publish is
|
|
||||||
# unique and can never collide with a real release on the latest tag.
|
|
||||||
- name: Version snapshot
|
|
||||||
if: steps.changesets.outputs.present == 'true'
|
|
||||||
run: pnpm exec changeset version --snapshot ${{ steps.prerelease.outputs.channel }}
|
|
||||||
|
|
||||||
- name: Build packages
|
|
||||||
if: steps.changesets.outputs.present == 'true'
|
|
||||||
run: pnpm build:packages
|
|
||||||
|
|
||||||
- name: Publish snapshot to NPM
|
|
||||||
if: steps.changesets.outputs.present == 'true'
|
|
||||||
run: pnpm exec changeset publish --tag ${{ steps.prerelease.outputs.channel }} --no-git-tag
|
|
||||||
|
|
||||||
- name: Collect prerelease info
|
|
||||||
if: steps.changesets.outputs.present == 'true'
|
|
||||||
run: node .github/collect-prerelease-info.js "${{ github.event.number }}" "${{ steps.prerelease.outputs.channel }}"
|
|
||||||
|
|
||||||
- name: Upload prerelease info
|
|
||||||
if: steps.changesets.outputs.present == 'true'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: prerelease-info
|
|
||||||
path: prerelease-info.json
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
|
if: ${{ github.repository_owner == 'shadcn-ui' }}
|
||||||
name: Create Version PR or Publish Stable Release
|
name: Create a PR for release workflow
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Use PNPM
|
- name: Use PNPM
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10.33.4
|
version: 9.0.6
|
||||||
|
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -150,24 +41,16 @@ jobs:
|
|||||||
- name: Install NPM Dependencies
|
- name: Install NPM Dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
# Builds every publishable package under packages/* (shadcn, @shadcn/react),
|
# - name: Check for errors
|
||||||
# never apps/v4, so each dist is fresh before changeset publish.
|
# run: pnpm check
|
||||||
- name: Build the packages
|
|
||||||
run: pnpm build:packages
|
|
||||||
|
|
||||||
- name: Import GPG key
|
- name: Build the package
|
||||||
uses: crazy-max/ghaction-import-gpg@v6
|
run: pnpm shadcn:build
|
||||||
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
|
- name: Create Version PR or Publish to NPM
|
||||||
id: changesets
|
id: changesets
|
||||||
uses: changesets/action@v1
|
uses: changesets/action@v1
|
||||||
with:
|
with:
|
||||||
setupGitUser: false
|
|
||||||
commit: "chore(release): version packages"
|
commit: "chore(release): version packages"
|
||||||
title: "chore(release): version packages"
|
title: "chore(release): version packages"
|
||||||
version: node .github/changeset-version.js
|
version: node .github/changeset-version.js
|
||||||
|
|||||||
75
.github/workflows/signed-commits.yml
vendored
75
.github/workflows/signed-commits.yml
vendored
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
314
.github/workflows/templates.yml
vendored
314
.github/workflows/templates.yml
vendored
@@ -1,314 +0,0 @@
|
|||||||
name: Templates
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: ["*"]
|
|
||||||
paths:
|
|
||||||
- ".github/workflows/templates.yml"
|
|
||||||
- "apps/v4/registry/**"
|
|
||||||
- "package.json"
|
|
||||||
- "packages/shadcn/src/commands/add.ts"
|
|
||||||
- "packages/shadcn/src/commands/init.ts"
|
|
||||||
- "packages/shadcn/src/templates/**"
|
|
||||||
- "packages/shadcn/src/utils/create-project.ts"
|
|
||||||
- "packages/shadcn/src/utils/get-monorepo-info.ts"
|
|
||||||
- "packages/shadcn/src/utils/get-package-manager.ts"
|
|
||||||
- "pnpm-lock.yaml"
|
|
||||||
- "templates/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: ${{ matrix.package-manager == 'pnpm' && format('pnpm {0}', matrix.pnpm-version) || matrix.package-manager }} ${{ matrix.template }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
timeout-minutes: 45
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
template: [next, vite, astro, start, react-router]
|
|
||||||
package-manager: [pnpm, bun, npm, yarn]
|
|
||||||
pnpm-version: [10.33.4, 11]
|
|
||||||
exclude:
|
|
||||||
- package-manager: bun
|
|
||||||
pnpm-version: 11
|
|
||||||
- package-manager: npm
|
|
||||||
pnpm-version: 11
|
|
||||||
- package-manager: yarn
|
|
||||||
pnpm-version: 11
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_APP_URL: http://localhost:4000
|
|
||||||
NEXT_PUBLIC_V0_URL: https://v0.dev
|
|
||||||
REGISTRY_URL: http://localhost:4000/r
|
|
||||||
ROOT_PNPM_VERSION: 10.33.4
|
|
||||||
TEMPLATE_PNPM_VERSION: ${{ matrix.pnpm-version }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Install Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
name: Install pnpm
|
|
||||||
id: pnpm-install
|
|
||||||
with:
|
|
||||||
version: ${{ env.ROOT_PNPM_VERSION }}
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install Yarn
|
|
||||||
if: matrix.package-manager == 'yarn'
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
COREPACK_ENABLE_PROJECT_SPEC=0 corepack prepare yarn@4.12.0 --activate
|
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
id: pnpm-cache
|
|
||||||
run: |
|
|
||||||
echo "pnpm_cache_dir=$(pnpm store path)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
name: Setup pnpm cache
|
|
||||||
with:
|
|
||||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Build packages
|
|
||||||
run: |
|
|
||||||
pnpm --filter=shadcn build
|
|
||||||
pnpm --filter=v4 registry:build
|
|
||||||
|
|
||||||
- name: Validate templates
|
|
||||||
env:
|
|
||||||
TEMPLATE: ${{ matrix.template }}
|
|
||||||
TEMPLATE_PACKAGE_MANAGER: ${{ matrix.package-manager }}
|
|
||||||
SHADCN_TEMPLATE_DIR: ${{ github.workspace }}/templates
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
root_pnpm="$(command -v pnpm)"
|
|
||||||
validation_script="$RUNNER_TEMP/validate-templates.sh"
|
|
||||||
|
|
||||||
cat > "$validation_script" <<'BASH'
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
bin_dir="$RUNNER_TEMP/template-pnpm-bin"
|
|
||||||
mkdir -p "$bin_dir"
|
|
||||||
|
|
||||||
cat > "$bin_dir/pnpm" <<'PNPM'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
exec npx -y "pnpm@${TEMPLATE_PNPM_VERSION}" "$@"
|
|
||||||
PNPM
|
|
||||||
chmod +x "$bin_dir/pnpm"
|
|
||||||
export PATH="$bin_dir:$PATH"
|
|
||||||
|
|
||||||
echo "Using template pnpm $(pnpm --version)"
|
|
||||||
|
|
||||||
cli="$GITHUB_WORKSPACE/packages/shadcn/dist/index.js"
|
|
||||||
template_root="$RUNNER_TEMP/generated-template-${TEMPLATE_PACKAGE_MANAGER}-${TEMPLATE}"
|
|
||||||
rm -rf "$template_root"
|
|
||||||
mkdir -p "$template_root"
|
|
||||||
|
|
||||||
modes=(app monorepo)
|
|
||||||
|
|
||||||
has_script() {
|
|
||||||
node -e "const pkg = require('./package.json'); process.exit(pkg.scripts && pkg.scripts[process.argv[1]] ? 0 : 1)" "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_script_if_present() {
|
|
||||||
local script="$1"
|
|
||||||
if has_script "$script"; then
|
|
||||||
pnpm run "$script"
|
|
||||||
else
|
|
||||||
echo "No $script script found; skipping."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_non_pnpm_project() {
|
|
||||||
local package_manager="$1"
|
|
||||||
local project_path="$2"
|
|
||||||
local check_workspace_protocol="$3"
|
|
||||||
local is_monorepo="$4"
|
|
||||||
|
|
||||||
cd "$project_path"
|
|
||||||
test ! -f pnpm-workspace.yaml
|
|
||||||
test ! -f pnpm-lock.yaml
|
|
||||||
|
|
||||||
EXPECTED_PACKAGE_MANAGER="$package_manager" \
|
|
||||||
CHECK_WORKSPACE_PROTOCOL="$check_workspace_protocol" \
|
|
||||||
IS_MONOREPO="$is_monorepo" \
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require("node:fs")
|
|
||||||
const path = require("node:path")
|
|
||||||
|
|
||||||
const expectedPackageManager = process.env.EXPECTED_PACKAGE_MANAGER
|
|
||||||
const checkWorkspaceProtocol =
|
|
||||||
process.env.CHECK_WORKSPACE_PROTOCOL === "true"
|
|
||||||
const isMonorepo = process.env.IS_MONOREPO === "true"
|
|
||||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
|
||||||
|
|
||||||
if (isMonorepo) {
|
|
||||||
const workspaces = pkg.workspaces ?? []
|
|
||||||
|
|
||||||
if (!Array.isArray(workspaces)) {
|
|
||||||
throw new Error("Expected package.json workspaces to be an array.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workspaces.length === 0) {
|
|
||||||
throw new Error("Expected package.json workspaces to have entries.")
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const workspace of ["sharp", "unrs-resolver", "esbuild"]) {
|
|
||||||
if (workspaces.includes(workspace)) {
|
|
||||||
throw new Error(`Unexpected workspace entry: ${workspace}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pkg.packageManager?.startsWith(`${expectedPackageManager}@`)) {
|
|
||||||
throw new Error(
|
|
||||||
`Expected packageManager to use ${expectedPackageManager}, got ${pkg.packageManager}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (pkg.workspaces !== undefined) {
|
|
||||||
throw new Error("Did not expect package.json workspaces for app template.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pkg.packageManager !== undefined) {
|
|
||||||
throw new Error(
|
|
||||||
`Did not expect packageManager for app template, got ${pkg.packageManager}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkWorkspaceProtocol) {
|
|
||||||
const packageJsonFiles = []
|
|
||||||
function collectPackageJsonFiles(dir) {
|
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
if (entry.name === "node_modules") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPath = path.join(dir, entry.name)
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
collectPackageJsonFiles(fullPath)
|
|
||||||
} else if (entry.name === "package.json") {
|
|
||||||
packageJsonFiles.push(fullPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
collectPackageJsonFiles(process.cwd())
|
|
||||||
|
|
||||||
for (const file of packageJsonFiles) {
|
|
||||||
const json = fs.readFileSync(file, "utf8")
|
|
||||||
if (json.includes("workspace:")) {
|
|
||||||
throw new Error(`Unexpected workspace: protocol in ${file}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NODE
|
|
||||||
}
|
|
||||||
|
|
||||||
for mode in "${modes[@]}"; do
|
|
||||||
project="test-${TEMPLATE}-${mode}-${TEMPLATE_PACKAGE_MANAGER}"
|
|
||||||
project_path="$template_root/$project"
|
|
||||||
|
|
||||||
echo "::group::${TEMPLATE} ${mode} ${TEMPLATE_PACKAGE_MANAGER}"
|
|
||||||
args=(
|
|
||||||
init
|
|
||||||
--defaults
|
|
||||||
--name "$project"
|
|
||||||
--template "$TEMPLATE"
|
|
||||||
--cwd "$template_root"
|
|
||||||
--silent
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ "$mode" = "monorepo" ]; then
|
|
||||||
args+=(--monorepo)
|
|
||||||
is_monorepo="true"
|
|
||||||
else
|
|
||||||
args+=(--no-monorepo)
|
|
||||||
is_monorepo="false"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$TEMPLATE_PACKAGE_MANAGER" in
|
|
||||||
pnpm)
|
|
||||||
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
|
|
||||||
REGISTRY_URL="$REGISTRY_URL" \
|
|
||||||
npm_config_user_agent="pnpm/${TEMPLATE_PNPM_VERSION}" \
|
|
||||||
node "$cli" "${args[@]}"
|
|
||||||
|
|
||||||
cd "$project_path"
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
run_script_if_present typecheck
|
|
||||||
run_script_if_present build
|
|
||||||
;;
|
|
||||||
bun)
|
|
||||||
(
|
|
||||||
cd "$template_root"
|
|
||||||
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
|
|
||||||
REGISTRY_URL="$REGISTRY_URL" \
|
|
||||||
npm_config_user_agent="bun/$(bun --version)" \
|
|
||||||
bunx --bun --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
|
||||||
shadcn "${args[@]}"
|
|
||||||
)
|
|
||||||
validate_non_pnpm_project \
|
|
||||||
"bun" \
|
|
||||||
"$project_path" \
|
|
||||||
"false" \
|
|
||||||
"$is_monorepo"
|
|
||||||
;;
|
|
||||||
npm)
|
|
||||||
(
|
|
||||||
cd "$template_root"
|
|
||||||
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
|
|
||||||
REGISTRY_URL="$REGISTRY_URL" \
|
|
||||||
npm_config_user_agent="npm/$(npm --version)" \
|
|
||||||
npx --yes --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
|
||||||
shadcn "${args[@]}"
|
|
||||||
)
|
|
||||||
validate_non_pnpm_project \
|
|
||||||
"npm" \
|
|
||||||
"$project_path" \
|
|
||||||
"true" \
|
|
||||||
"$is_monorepo"
|
|
||||||
;;
|
|
||||||
yarn)
|
|
||||||
(
|
|
||||||
cd "$template_root"
|
|
||||||
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
|
|
||||||
REGISTRY_URL="$REGISTRY_URL" \
|
|
||||||
COREPACK_ENABLE_PROJECT_SPEC=0 \
|
|
||||||
npm_config_user_agent="yarn/$(COREPACK_ENABLE_PROJECT_SPEC=0 yarn --version)" \
|
|
||||||
yarn dlx --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
|
||||||
shadcn "${args[@]}"
|
|
||||||
)
|
|
||||||
validate_non_pnpm_project \
|
|
||||||
"yarn" \
|
|
||||||
"$project_path" \
|
|
||||||
"false" \
|
|
||||||
"$is_monorepo"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo "::endgroup::"
|
|
||||||
done
|
|
||||||
BASH
|
|
||||||
|
|
||||||
"$root_pnpm" exec start-server-and-test \
|
|
||||||
"$root_pnpm v4:dev" \
|
|
||||||
http://localhost:4000 \
|
|
||||||
"bash $validation_script"
|
|
||||||
41
.github/workflows/test.yml
vendored
41
.github/workflows/test.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
id: pnpm-install
|
id: pnpm-install
|
||||||
with:
|
with:
|
||||||
version: 10.33.4
|
version: 9.0.6
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
@@ -39,46 +39,7 @@ jobs:
|
|||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
- name: Install Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- run: pnpm test
|
- run: pnpm test
|
||||||
|
|
||||||
react:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: pnpm test (@shadcn/react)
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
name: Install pnpm
|
|
||||||
id: pnpm-install
|
|
||||||
with:
|
|
||||||
version: 10.33.4
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
id: pnpm-cache
|
|
||||||
run: |
|
|
||||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
|
||||||
- uses: actions/cache@v3
|
|
||||||
name: Setup pnpm cache
|
|
||||||
with:
|
|
||||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- run: pnpm --filter=@shadcn/react test
|
|
||||||
|
|||||||
75
.github/workflows/validate-registries.yml
vendored
75
.github/workflows/validate-registries.yml
vendored
@@ -3,14 +3,54 @@ name: Validate Registries
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
|
- "apps/v4/public/r/registries.json"
|
||||||
- "apps/v4/registry/directory.json"
|
- "apps/v4/registry/directory.json"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
|
- "apps/v4/public/r/registries.json"
|
||||||
- "apps/v4/registry/directory.json"
|
- "apps/v4/registry/directory.json"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
check-registry-sync:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Check registry sync
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Check changed files
|
||||||
|
id: changed
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
CHANGED_FILES=$(gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --name-only)
|
||||||
|
|
||||||
|
DIRECTORY_CHANGED=false
|
||||||
|
REGISTRIES_CHANGED=false
|
||||||
|
|
||||||
|
if echo "$CHANGED_FILES" | grep -q "^apps/v4/registry/directory.json$"; then
|
||||||
|
DIRECTORY_CHANGED=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$CHANGED_FILES" | grep -q "^apps/v4/public/r/registries.json$"; then
|
||||||
|
REGISTRIES_CHANGED=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "directory_changed=$DIRECTORY_CHANGED" >> $GITHUB_OUTPUT
|
||||||
|
echo "registries_changed=$REGISTRIES_CHANGED" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Flag missing registries.json update
|
||||||
|
if: steps.changed.outputs.directory_changed == 'true' && steps.changed.outputs.registries_changed == 'false'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh pr edit ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --add-label "registries: invalid"
|
||||||
|
gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body "can you run \`pnpm registry:build\` and commit the json files please?"
|
||||||
|
exit 1
|
||||||
|
|
||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: pnpm validate:registries
|
name: pnpm validate:registries
|
||||||
@@ -26,44 +66,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
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 file = "apps/v4/registry/directory.json"
|
|
||||||
const reservedNamespaces = new Set(
|
|
||||||
process.env.RESERVED_NAMESPACES.split(",").filter(Boolean)
|
|
||||||
)
|
|
||||||
|
|
||||||
function readNames() {
|
|
||||||
return JSON.parse(fs.readFileSync(file, "utf8")).map(
|
|
||||||
(entry) => entry.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const violations = readNames()
|
|
||||||
.filter((name) => reservedNamespaces.has(name))
|
|
||||||
.map((name) => `${file}: ${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
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
id: pnpm-install
|
id: pnpm-install
|
||||||
with:
|
with:
|
||||||
version: 10.33.4
|
version: 9.0.6
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -15,7 +15,6 @@ build
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
@@ -43,13 +42,4 @@ tsconfig.tsbuildinfo
|
|||||||
|
|
||||||
.notes
|
.notes
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
.playwright-cli
|
|
||||||
shadcn-workspace
|
shadcn-workspace
|
||||||
|
|
||||||
# vitest browser mode writes these only on test failure.
|
|
||||||
__screenshots__
|
|
||||||
.codex-artifacts
|
|
||||||
.tmp*
|
|
||||||
|
|
||||||
CONTEXT.md
|
|
||||||
docs/adr
|
|
||||||
|
|||||||
@@ -5,4 +5,3 @@ build
|
|||||||
.contentlayer
|
.contentlayer
|
||||||
**/fixtures
|
**/fixtures
|
||||||
deprecated
|
deprecated
|
||||||
apps/v4/registry/styles/**/*.css
|
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -15,7 +15,6 @@
|
|||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"apps/v4/registry/radix-*": true,
|
"apps/v4/registry/radix-*": true,
|
||||||
"apps/v4/public/r/*": true,
|
"apps/v4/public/r/*": true,
|
||||||
"packages/shadcn/test/fixtures/*": true,
|
"packages/shadcn/test/fixtures/*": true
|
||||||
"apps/v4/styles/*": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,11 +141,6 @@ When adding or modifying components, please ensure that:
|
|||||||
2. You update the documentation.
|
2. You update the documentation.
|
||||||
3. You run `pnpm registry:build` to update the registry.
|
3. You run `pnpm registry:build` to update the registry.
|
||||||
|
|
||||||
See [`apps/v4/registry/README.md`](apps/v4/registry/README.md) for how the
|
|
||||||
registry pipeline is structured and for the faster targeted build modes
|
|
||||||
(`--style`, `--registry`, `--examples`, `--indexes`) you can use while
|
|
||||||
iterating locally. Always run the full `pnpm registry:build` before committing.
|
|
||||||
|
|
||||||
## Commit Convention
|
## Commit Convention
|
||||||
|
|
||||||
Before you create a Pull Request, please check whether your commits comply with
|
Before you create a Pull Request, please check whether your commits comply with
|
||||||
|
|||||||
59
RELEASING.md
59
RELEASING.md
@@ -1,59 +0,0 @@
|
|||||||
# Releasing
|
|
||||||
|
|
||||||
This monorepo publishes two packages independently with [Changesets](https://github.com/changesets/changesets):
|
|
||||||
|
|
||||||
- **`shadcn`** — the CLI and tooling.
|
|
||||||
- **`@shadcn/react`** — headless React primitives.
|
|
||||||
|
|
||||||
They version on their own lines. A change to one never bumps the other unless a changeset says so.
|
|
||||||
|
|
||||||
## 1. Add a changeset
|
|
||||||
|
|
||||||
Every change that should publish needs a changeset. Run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm changeset
|
|
||||||
```
|
|
||||||
|
|
||||||
Select the affected package(s) and bump level. One PR can carry separate changesets for `shadcn` and `@shadcn/react` at different levels. A PR with no changeset publishes nothing.
|
|
||||||
|
|
||||||
## 2. Stable release
|
|
||||||
|
|
||||||
Stable releases are automated by `.github/workflows/release.yml` (the `release` job, on push to `main`):
|
|
||||||
|
|
||||||
1. Merged changesets accumulate on `main`.
|
|
||||||
2. The Changesets action opens/updates a **"Version Packages"** PR that bumps versions and writes changelogs.
|
|
||||||
3. Merging that PR triggers `changeset publish`, which builds all packages (`pnpm build:packages`) and publishes any whose version is ahead of npm — each to the `latest` tag.
|
|
||||||
|
|
||||||
`pnpm build:packages` (`turbo run build --filter=./packages/*`) builds `shadcn` and `@shadcn/react` but never `apps/v4`.
|
|
||||||
|
|
||||||
## 3. Prereleases (per-PR snapshots)
|
|
||||||
|
|
||||||
Add the **`release: beta`** or **`release: rc`** label to a PR. The `prerelease` job in `release.yml`:
|
|
||||||
|
|
||||||
1. Verifies the branch has changesets (a label on the version PR is a no-op, since it consumed them).
|
|
||||||
2. Runs `changeset version --snapshot <channel>` — stamps a unique `0.0.0-<channel>-<timestamp>` on each changeset'd package.
|
|
||||||
3. Builds and runs `changeset publish --tag <channel> --no-git-tag`.
|
|
||||||
4. Uploads the published package list; `prerelease-comment.yml` posts a `pnpm dlx` install line per package and removes the label.
|
|
||||||
|
|
||||||
The label selects the **dist-tag/channel**; the **changesets on the branch** select which packages publish. Snapshots are timestamped, so they never touch `latest` and never collide.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Install a snapshot from the PR comment, e.g.:
|
|
||||||
pnpm dlx @shadcn/react@0.0.0-beta-20260624120000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Prerelease trains (sustained `-beta.N` / `-rc.N`)
|
|
||||||
|
|
||||||
For a baking release line (e.g. `1.0.0-rc.0`, `-rc.1`, …) rather than throwaway snapshots, use Changesets pre mode:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm changeset pre enter rc # writes .changeset/pre.json
|
|
||||||
# ...normal changeset + Version PR cycle now produces -rc.N versions on the rc tag...
|
|
||||||
pnpm changeset pre exit # back to stable; next Version PR ships X.Y.Z on latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `pnpm-workspace.yaml` sets `minimumReleaseAge: 2880` (48h), so freshly published stable/beta versions take time to resolve in normal installs. Use `pnpm dlx <pkg>@<exact-snapshot-version>` to test immediately.
|
|
||||||
- Publishing uses npm OIDC/provenance (`id-token: write` + `npm@latest`); no `NPM_TOKEN` secret is needed.
|
|
||||||
1
apps/v4/.gitignore
vendored
1
apps/v4/.gitignore
vendored
@@ -46,4 +46,3 @@ next-env.d.ts
|
|||||||
.contentlayer
|
.contentlayer
|
||||||
.content-collections
|
.content-collections
|
||||||
.source
|
.source
|
||||||
.devtools
|
|
||||||
|
|||||||
@@ -5,4 +5,3 @@ build
|
|||||||
.contentlayer
|
.contentlayer
|
||||||
registry/__index__.tsx
|
registry/__index__.tsx
|
||||||
content/docs/components/calendar.mdx
|
content/docs/components/calendar.mdx
|
||||||
registry/styles/**/*.css
|
|
||||||
|
|||||||
@@ -1,130 +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]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group/picker relative">
|
|
||||||
<Picker>
|
|
||||||
<PickerTrigger>
|
|
||||||
<div className="flex flex-col justify-start text-left">
|
|
||||||
<div className="text-xs text-muted-foreground">Chart Color</div>
|
|
||||||
<div className="text-sm font-medium text-foreground">
|
|
||||||
{currentChartColor?.title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{mounted && (
|
|
||||||
<div
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--color":
|
|
||||||
currentChartColor?.cssVars?.dark?.[
|
|
||||||
currentChartColorIsBaseColor
|
|
||||||
? "muted-foreground"
|
|
||||||
: "primary"
|
|
||||||
],
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 rounded-full bg-(--color) select-none md:right-2.5"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PickerTrigger>
|
|
||||||
<PickerContent
|
|
||||||
anchor={isMobile ? anchorRef : undefined}
|
|
||||||
side={isMobile ? "top" : "right"}
|
|
||||||
align={isMobile ? "center" : "start"}
|
|
||||||
className="max-h-92"
|
|
||||||
>
|
|
||||||
<PickerRadioGroup
|
|
||||||
value={currentChartColor?.name}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setParams({ chartColor: value as ChartColorName })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PickerGroup>
|
|
||||||
{availableChartColors
|
|
||||||
.filter((theme) =>
|
|
||||||
BASE_COLORS.find((baseColor) => baseColor.name === theme.name)
|
|
||||||
)
|
|
||||||
.map((theme) => (
|
|
||||||
<PickerRadioItem
|
|
||||||
key={theme.name}
|
|
||||||
value={theme.name}
|
|
||||||
closeOnClick={isMobile}
|
|
||||||
>
|
|
||||||
{theme.title}
|
|
||||||
</PickerRadioItem>
|
|
||||||
))}
|
|
||||||
</PickerGroup>
|
|
||||||
<PickerSeparator />
|
|
||||||
<PickerGroup>
|
|
||||||
{availableChartColors
|
|
||||||
.filter(
|
|
||||||
(theme) =>
|
|
||||||
!BASE_COLORS.find(
|
|
||||||
(baseColor) => baseColor.name === theme.name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.map((theme) => (
|
|
||||||
<PickerRadioItem
|
|
||||||
key={theme.name}
|
|
||||||
value={theme.name}
|
|
||||||
closeOnClick={isMobile}
|
|
||||||
>
|
|
||||||
{theme.title}
|
|
||||||
</PickerRadioItem>
|
|
||||||
))}
|
|
||||||
</PickerGroup>
|
|
||||||
</PickerRadioGroup>
|
|
||||||
</PickerContent>
|
|
||||||
</Picker>
|
|
||||||
<LockButton
|
|
||||||
param="chartColor"
|
|
||||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { BASES } from "@/registry/config"
|
|
||||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
|
||||||
import { getDocsPathForItem } from "@/app/(app)/(create)/lib/devtools"
|
|
||||||
import {
|
|
||||||
serializeDesignSystemSearchParams,
|
|
||||||
useDesignSystemSearchParams,
|
|
||||||
} from "@/app/(app)/(create)/lib/search-params"
|
|
||||||
|
|
||||||
export function CreateDevtools() {
|
|
||||||
const [params, setParams] = useDesignSystemSearchParams()
|
|
||||||
|
|
||||||
const previewUrl = React.useMemo(
|
|
||||||
() =>
|
|
||||||
serializeDesignSystemSearchParams(
|
|
||||||
`/preview/${params.base}/${params.item}`,
|
|
||||||
params
|
|
||||||
),
|
|
||||||
[params]
|
|
||||||
)
|
|
||||||
|
|
||||||
const docsUrl = React.useMemo(
|
|
||||||
() => getDocsPathForItem(params.base, params.item),
|
|
||||||
[params.base, params.item]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dark absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 items-center gap-0.5 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
|
|
||||||
{BASES.map((base) => (
|
|
||||||
<Button
|
|
||||||
key={base.name}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
data-active={params.base === base.name}
|
|
||||||
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({ base: base.name })}
|
|
||||||
>
|
|
||||||
{base.name === "radix" ? "Radix" : "Base"}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
<div className="mx-0.5 h-4 w-px bg-border/80" />
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<a href={previewUrl} target="_blank" rel="noreferrer">
|
|
||||||
Open in New Tab
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<div className="mx-0.5 h-4 w-px bg-border/80" />
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<a href={docsUrl} target="_blank" rel="noreferrer">
|
|
||||||
Open Docs
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
|
||||||
|
|
||||||
export function CreateSkeleton() {
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Skeleton className="flex-1 rounded-2xl" />
|
|
||||||
<Skeleton className="min-h-[151px] w-full self-start rounded-2xl md:h-full md:max-h-full md:min-h-0 md:w-(--customizer-width)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,131 +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 { Button } from "@/styles/base-nova/ui/button"
|
|
||||||
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". Rendered client-only to
|
|
||||||
// avoid a useId hydration mismatch on the Base UI dialog trigger. The loading
|
|
||||||
// placeholder mirrors the trigger button exactly so there is no layout shift.
|
|
||||||
const ProjectForm = dynamic(
|
|
||||||
() =>
|
|
||||||
import("@/app/(app)/(create)/components/project-form").then(
|
|
||||||
(m) => m.ProjectForm
|
|
||||||
),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<Button disabled aria-hidden>
|
|
||||||
Get Code
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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 backdrop-blur-xl md:w-(--customizer-width)"
|
|
||||||
ref={anchorRef}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<CardHeader className="hidden items-center justify-between gap-2 border-b group-data-reversed/layout:flex-row-reverse md:flex">
|
|
||||||
<MainMenu />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="no-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden md:overflow-y-auto">
|
|
||||||
<FieldGroup className="flex-row gap-2.5 py-px **:data-[slot=field-separator]:-mx-4 **:data-[slot=field-separator]:w-auto md:flex-col md:gap-3.25">
|
|
||||||
<StylePicker
|
|
||||||
styles={STYLES}
|
|
||||||
isMobile={isMobile}
|
|
||||||
anchorRef={anchorRef}
|
|
||||||
/>
|
|
||||||
<FieldSeparator className="hidden md:block" />
|
|
||||||
<BaseColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
|
||||||
<ThemePicker
|
|
||||||
themes={availableThemes}
|
|
||||||
isMobile={isMobile}
|
|
||||||
anchorRef={anchorRef}
|
|
||||||
/>
|
|
||||||
<ChartColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
|
||||||
<FieldSeparator className="hidden md:block" />
|
|
||||||
<FontPicker
|
|
||||||
label="Heading"
|
|
||||||
param="fontHeading"
|
|
||||||
fonts={FONT_HEADING_OPTIONS}
|
|
||||||
isMobile={isMobile}
|
|
||||||
anchorRef={anchorRef}
|
|
||||||
/>
|
|
||||||
<FontPicker
|
|
||||||
label="Font"
|
|
||||||
param="font"
|
|
||||||
fonts={FONTS}
|
|
||||||
isMobile={isMobile}
|
|
||||||
anchorRef={anchorRef}
|
|
||||||
/>
|
|
||||||
<FieldSeparator className="hidden md:block" />
|
|
||||||
<IconLibraryPicker isMobile={isMobile} anchorRef={anchorRef} />
|
|
||||||
<RadiusPicker isMobile={isMobile} anchorRef={anchorRef} />
|
|
||||||
<FieldSeparator className="hidden md:block" />
|
|
||||||
<MenuColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
|
||||||
<MenuAccentPicker isMobile={isMobile} anchorRef={anchorRef} />
|
|
||||||
{isMobile && <BasePicker isMobile={isMobile} anchorRef={anchorRef} />}
|
|
||||||
</FieldGroup>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
|
|
||||||
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
|
|
||||||
<OpenPreset
|
|
||||||
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
|
|
||||||
label={isMobile ? "Open" : "Open Preset"}
|
|
||||||
/>
|
|
||||||
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
|
|
||||||
<ActionMenu itemsByBase={itemsByBase} />
|
|
||||||
<ResetDialog />
|
|
||||||
</CardFooter>
|
|
||||||
<CardFooter className="-mt-3 hidden min-w-0 gap-2 md:flex md:flex-col md:**:[button,a]:w-full">
|
|
||||||
<ProjectForm />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,323 +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" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (style === "rhea" && radius === "large") {
|
|
||||||
setSearchParams({ radius: "default" })
|
|
||||||
}
|
|
||||||
}, [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}</>
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
|
||||||
import {
|
|
||||||
Picker,
|
|
||||||
PickerContent,
|
|
||||||
PickerGroup,
|
|
||||||
PickerLabel,
|
|
||||||
PickerRadioGroup,
|
|
||||||
PickerRadioItem,
|
|
||||||
PickerSeparator,
|
|
||||||
PickerTrigger,
|
|
||||||
} from "@/app/(app)/(create)/components/picker"
|
|
||||||
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
|
|
||||||
import {
|
|
||||||
useDesignSystemSearchParams,
|
|
||||||
type DesignSystemSearchParams,
|
|
||||||
} from "@/app/(app)/(create)/lib/search-params"
|
|
||||||
|
|
||||||
type FontPickerOption = {
|
|
||||||
name: string
|
|
||||||
value: string
|
|
||||||
type: string
|
|
||||||
font: {
|
|
||||||
style: {
|
|
||||||
fontFamily: string
|
|
||||||
}
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FontPicker({
|
|
||||||
label,
|
|
||||||
param,
|
|
||||||
fonts,
|
|
||||||
isMobile,
|
|
||||||
anchorRef,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
param: "font" | "fontHeading"
|
|
||||||
fonts: readonly FontPickerOption[]
|
|
||||||
isMobile: boolean
|
|
||||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
|
||||||
}) {
|
|
||||||
const [params, setParams] = useDesignSystemSearchParams()
|
|
||||||
const currentValue = param === "font" ? params.font : params.fontHeading
|
|
||||||
const handleFontChange = React.useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setParams({
|
|
||||||
[param]: value,
|
|
||||||
} as Partial<DesignSystemSearchParams>)
|
|
||||||
},
|
|
||||||
[param, setParams]
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentFont = React.useMemo(
|
|
||||||
() => fonts.find((font) => font.value === currentValue),
|
|
||||||
[fonts, currentValue]
|
|
||||||
)
|
|
||||||
const currentBodyFont = React.useMemo(
|
|
||||||
() => FONTS.find((font) => font.value === params.font),
|
|
||||||
[params.font]
|
|
||||||
)
|
|
||||||
const inheritsBodyFont = param === "fontHeading" && currentValue === "inherit"
|
|
||||||
const displayFontName = inheritsBodyFont
|
|
||||||
? currentBodyFont?.name
|
|
||||||
: currentFont?.name
|
|
||||||
const inheritFontLabel = currentBodyFont ? currentBodyFont.name : "Body font"
|
|
||||||
const groupedFonts = React.useMemo(() => {
|
|
||||||
const pickerFonts =
|
|
||||||
param === "fontHeading"
|
|
||||||
? fonts.filter((font) => font.value !== "inherit")
|
|
||||||
: fonts
|
|
||||||
const groups = new Map<string, FontPickerOption[]>()
|
|
||||||
|
|
||||||
for (const font of pickerFonts) {
|
|
||||||
const existing = groups.get(font.type)
|
|
||||||
if (existing) {
|
|
||||||
existing.push(font)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
groups.set(font.type, [font])
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(groups.entries()).map(([type, items]) => ({
|
|
||||||
type,
|
|
||||||
label: `${type.charAt(0).toUpperCase()}${type.slice(1)}`,
|
|
||||||
items,
|
|
||||||
}))
|
|
||||||
}, [fonts, param])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group/picker relative">
|
|
||||||
<Picker>
|
|
||||||
<PickerTrigger>
|
|
||||||
<div className="flex flex-col justify-start text-left">
|
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
|
||||||
<div className="line-clamp-1 max-w-[80%] truncate text-sm font-medium text-foreground">
|
|
||||||
{displayFontName}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5"
|
|
||||||
style={{
|
|
||||||
fontFamily:
|
|
||||||
currentFont?.font?.style.fontFamily ??
|
|
||||||
currentBodyFont?.font.style.fontFamily,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Aa
|
|
||||||
</div>
|
|
||||||
</PickerTrigger>
|
|
||||||
<PickerContent
|
|
||||||
anchor={isMobile ? anchorRef : undefined}
|
|
||||||
side={isMobile ? "top" : "right"}
|
|
||||||
align={isMobile ? "center" : "start"}
|
|
||||||
className="max-h-96"
|
|
||||||
>
|
|
||||||
<PickerRadioGroup
|
|
||||||
value={currentValue}
|
|
||||||
onValueChange={handleFontChange}
|
|
||||||
>
|
|
||||||
{param === "fontHeading" ? (
|
|
||||||
<>
|
|
||||||
<PickerGroup>
|
|
||||||
<PickerRadioItem value="inherit" closeOnClick={isMobile}>
|
|
||||||
{inheritFontLabel}
|
|
||||||
</PickerRadioItem>
|
|
||||||
</PickerGroup>
|
|
||||||
<PickerSeparator />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{groupedFonts.map((group) => (
|
|
||||||
<PickerGroup key={group.type}>
|
|
||||||
<PickerLabel>{group.label}</PickerLabel>
|
|
||||||
{group.items.map((font) => (
|
|
||||||
<PickerRadioItem
|
|
||||||
key={font.value}
|
|
||||||
value={font.value}
|
|
||||||
closeOnClick={isMobile}
|
|
||||||
>
|
|
||||||
{font.name}
|
|
||||||
</PickerRadioItem>
|
|
||||||
))}
|
|
||||||
</PickerGroup>
|
|
||||||
))}
|
|
||||||
</PickerRadioGroup>
|
|
||||||
</PickerContent>
|
|
||||||
</Picker>
|
|
||||||
<LockButton
|
|
||||||
param={param}
|
|
||||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Menu02Icon } from "@hugeicons/core-free-icons"
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
|
||||||
import { useTheme } from "next-themes"
|
|
||||||
|
|
||||||
import { useMounted } from "@/hooks/use-mounted"
|
|
||||||
import { type MenuColorValue } from "@/registry/config"
|
|
||||||
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
|
||||||
import {
|
|
||||||
Picker,
|
|
||||||
PickerContent,
|
|
||||||
PickerGroup,
|
|
||||||
PickerLabel,
|
|
||||||
PickerRadioGroup,
|
|
||||||
PickerRadioItem,
|
|
||||||
PickerSeparator,
|
|
||||||
PickerTrigger,
|
|
||||||
} from "@/app/(app)/(create)/components/picker"
|
|
||||||
import {
|
|
||||||
isTranslucentMenuColor,
|
|
||||||
useDesignSystemSearchParams,
|
|
||||||
} from "@/app/(app)/(create)/lib/search-params"
|
|
||||||
|
|
||||||
type ColorChoice = "default" | "inverted"
|
|
||||||
type SurfaceChoice = "solid" | "translucent"
|
|
||||||
|
|
||||||
function getMenuColorValue(
|
|
||||||
color: ColorChoice,
|
|
||||||
translucent: boolean
|
|
||||||
): MenuColorValue {
|
|
||||||
if (color === "default") {
|
|
||||||
return translucent ? "default-translucent" : "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
return translucent ? "inverted-translucent" : "inverted"
|
|
||||||
}
|
|
||||||
|
|
||||||
const MENU_OPTIONS: { value: MenuColorValue; label: string }[] = [
|
|
||||||
{ value: "default", label: "Default / Solid" },
|
|
||||||
{ value: "default-translucent", label: "Default / Translucent" },
|
|
||||||
{ value: "inverted", label: "Inverted / Solid" },
|
|
||||||
{ value: "inverted-translucent", label: "Inverted / Translucent" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function MenuColorPicker({
|
|
||||||
isMobile,
|
|
||||||
anchorRef,
|
|
||||||
}: {
|
|
||||||
isMobile: boolean
|
|
||||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
|
||||||
}) {
|
|
||||||
const [params, setParams] = useDesignSystemSearchParams()
|
|
||||||
const { resolvedTheme } = useTheme()
|
|
||||||
const mounted = useMounted()
|
|
||||||
const lastSolidMenuAccentRef = React.useRef(params.menuAccent)
|
|
||||||
const isDark = mounted && resolvedTheme === "dark"
|
|
||||||
const currentMenu = MENU_OPTIONS.find(
|
|
||||||
(menu) => menu.value === params.menuColor
|
|
||||||
)
|
|
||||||
const colorChoice: ColorChoice =
|
|
||||||
params.menuColor === "inverted" ||
|
|
||||||
params.menuColor === "inverted-translucent"
|
|
||||||
? "inverted"
|
|
||||||
: "default"
|
|
||||||
const surfaceChoice: SurfaceChoice =
|
|
||||||
params.menuColor === "default-translucent" ||
|
|
||||||
params.menuColor === "inverted-translucent"
|
|
||||||
? "translucent"
|
|
||||||
: "solid"
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (surfaceChoice === "solid") {
|
|
||||||
lastSolidMenuAccentRef.current = params.menuAccent
|
|
||||||
}
|
|
||||||
}, [params.menuAccent, surfaceChoice])
|
|
||||||
|
|
||||||
const setColor = (color: ColorChoice) => {
|
|
||||||
const nextMenuColor = getMenuColorValue(
|
|
||||||
color,
|
|
||||||
surfaceChoice === "translucent"
|
|
||||||
)
|
|
||||||
|
|
||||||
setParams({
|
|
||||||
menuColor: nextMenuColor,
|
|
||||||
...(isTranslucentMenuColor(nextMenuColor) && { menuAccent: "subtle" }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const setSurface = (choice: SurfaceChoice) => {
|
|
||||||
const isTranslucent = choice === "translucent"
|
|
||||||
const nextMenuColor = getMenuColorValue(colorChoice, isTranslucent)
|
|
||||||
|
|
||||||
setParams({
|
|
||||||
menuColor: nextMenuColor,
|
|
||||||
menuAccent: isTranslucent ? "subtle" : lastSolidMenuAccentRef.current,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group/picker relative">
|
|
||||||
<Picker>
|
|
||||||
<PickerTrigger>
|
|
||||||
<div className="flex flex-col justify-start text-left">
|
|
||||||
<div className="text-xs text-muted-foreground">Menu</div>
|
|
||||||
<div className="line-clamp-1 max-w-[80%] truncate text-sm font-medium text-foreground">
|
|
||||||
{currentMenu?.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5">
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={Menu02Icon}
|
|
||||||
strokeWidth={2}
|
|
||||||
className="size-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PickerTrigger>
|
|
||||||
<PickerContent
|
|
||||||
anchor={isMobile ? anchorRef : undefined}
|
|
||||||
side={isMobile ? "top" : "right"}
|
|
||||||
align={isMobile ? "center" : "start"}
|
|
||||||
>
|
|
||||||
<PickerGroup>
|
|
||||||
<PickerLabel>Color</PickerLabel>
|
|
||||||
<PickerRadioGroup
|
|
||||||
value={colorChoice}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setColor(value as ColorChoice)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PickerRadioItem value="default" closeOnClick={isMobile}>
|
|
||||||
Default
|
|
||||||
</PickerRadioItem>
|
|
||||||
<PickerRadioItem
|
|
||||||
value="inverted"
|
|
||||||
closeOnClick={isMobile}
|
|
||||||
disabled={isDark}
|
|
||||||
>
|
|
||||||
Inverted
|
|
||||||
</PickerRadioItem>
|
|
||||||
</PickerRadioGroup>
|
|
||||||
</PickerGroup>
|
|
||||||
<PickerSeparator />
|
|
||||||
<PickerGroup>
|
|
||||||
<PickerLabel>Appearance</PickerLabel>
|
|
||||||
<PickerRadioGroup
|
|
||||||
value={surfaceChoice}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSurface(value as SurfaceChoice)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PickerRadioItem value="solid" closeOnClick={isMobile}>
|
|
||||||
Solid
|
|
||||||
</PickerRadioItem>
|
|
||||||
<PickerRadioItem value="translucent" closeOnClick={isMobile}>
|
|
||||||
Translucent
|
|
||||||
</PickerRadioItem>
|
|
||||||
</PickerRadioGroup>
|
|
||||||
</PickerGroup>
|
|
||||||
</PickerContent>
|
|
||||||
</Picker>
|
|
||||||
<LockButton
|
|
||||||
param="menuColor"
|
|
||||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import Script from "next/script"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/styles/base-nova/ui/button"
|
|
||||||
import { useThemeToggle } from "@/app/(app)/(create)/hooks/use-theme-toggle"
|
|
||||||
|
|
||||||
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
|
|
||||||
|
|
||||||
export function ModeSwitcher({
|
|
||||||
variant = "ghost",
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
variant?: React.ComponentProps<typeof Button>["variant"]
|
|
||||||
className?: React.ComponentProps<typeof Button>["className"]
|
|
||||||
}) {
|
|
||||||
const { toggleTheme } = useThemeToggle()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant={variant}
|
|
||||||
size="icon"
|
|
||||||
className={cn("group/toggle extend-touch-target", className)}
|
|
||||||
onClick={toggleTheme}
|
|
||||||
id="mode-switcher-button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="size-4.5"
|
|
||||||
>
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
|
||||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
|
||||||
<path d="M12 3l0 18" />
|
|
||||||
<path d="M12 9l4.65 -4.65" />
|
|
||||||
<path d="M12 14.3l7.37 -7.37" />
|
|
||||||
<path d="M12 19.6l8.85 -8.85" />
|
|
||||||
</svg>
|
|
||||||
<span className="sr-only">Toggle theme</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DarkModeScript() {
|
|
||||||
return (
|
|
||||||
<Script
|
|
||||||
id="dark-mode-listener"
|
|
||||||
strategy="beforeInteractive"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
(function() {
|
|
||||||
// Forward D key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if ((e.key === 'd' || e.key === 'D') && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
||||||
if (
|
|
||||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
|
||||||
e.target instanceof HTMLInputElement ||
|
|
||||||
e.target instanceof HTMLTextAreaElement ||
|
|
||||||
e.target instanceof HTMLSelectElement
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
if (window.parent && window.parent !== window) {
|
|
||||||
window.parent.postMessage({
|
|
||||||
type: '${DARK_MODE_FORWARD_TYPE}',
|
|
||||||
key: e.key
|
|
||||||
}, '*');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
})();
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,203 +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
|
|
||||||
render={<Button variant="outline" className={triggerClassName} />}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</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
|
|
||||||
render={
|
|
||||||
<Button variant="outline" type="button" className="h-10" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</DrawerClose>
|
|
||||||
</DrawerFooter>
|
|
||||||
</form>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
|
|
||||||
<DialogContent className="dark">
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{PRESET_TITLE}</DialogTitle>
|
|
||||||
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="py-4">{fields}</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose render={<Button variant="outline" type="button" />}>
|
|
||||||
Cancel
|
|
||||||
</DialogClose>
|
|
||||||
<Button type="submit" disabled={!nextPreset}>
|
|
||||||
Open
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenPresetScript() {
|
|
||||||
return (
|
|
||||||
<Script
|
|
||||||
id="open-preset-listener"
|
|
||||||
strategy="beforeInteractive"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
(function() {
|
|
||||||
// Forward O key.
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
||||||
if (
|
|
||||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
|
||||||
e.target instanceof HTMLInputElement ||
|
|
||||||
e.target instanceof HTMLTextAreaElement ||
|
|
||||||
e.target instanceof HTMLSelectElement
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
if (window.parent && window.parent !== window) {
|
|
||||||
window.parent.postMessage({
|
|
||||||
type: '${OPEN_PRESET_FORWARD_TYPE}',
|
|
||||||
key: e.key
|
|
||||||
}, '*');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
})();
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
|
||||||
|
|
||||||
const PREVIEW_ITEMS = [
|
|
||||||
{ label: "01", value: "preview-02" },
|
|
||||||
{ label: "02", value: "preview" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function PreviewSwitcher() {
|
|
||||||
const [params, setParams] = useDesignSystemSearchParams()
|
|
||||||
|
|
||||||
const isPreview =
|
|
||||||
params.item === "preview" || params.item.startsWith("preview-0")
|
|
||||||
|
|
||||||
if (!isPreview) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dark absolute right-3 bottom-3 z-20 flex items-center gap-1 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
|
|
||||||
{PREVIEW_ITEMS.map((item) => (
|
|
||||||
<Button
|
|
||||||
key={item.value}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
data-active={params.item === item.value}
|
|
||||||
className="h-7 min-w-8 cursor-pointer rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
|
|
||||||
onClick={() => setParams({ item: item.value })}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/(create)/components/action-menu"
|
|
||||||
import { CreateDevtools } from "@/app/(app)/(create)/components/create-devtools"
|
|
||||||
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>
|
|
||||||
<CreateDevtools />
|
|
||||||
<PreviewSwitcher />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,726 +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,
|
|
||||||
buildThemeForPreset,
|
|
||||||
DEFAULT_CONFIG,
|
|
||||||
type BaseName,
|
|
||||||
type DesignSystemConfig,
|
|
||||||
} from "@/registry/config"
|
|
||||||
import { Button } from "@/styles/base-nova/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/styles/base-nova/ui/dialog"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldDescription,
|
|
||||||
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 {
|
|
||||||
ToggleGroup,
|
|
||||||
ToggleGroupItem,
|
|
||||||
} from "@/styles/base-nova/ui/toggle-group"
|
|
||||||
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]
|
|
||||||
|
|
||||||
const APPLY_MODES = [
|
|
||||||
{
|
|
||||||
value: "full",
|
|
||||||
title: "Full preset",
|
|
||||||
description:
|
|
||||||
"Everything from the preset, including components, theme, and fonts.",
|
|
||||||
flag: null,
|
|
||||||
label: "full preset",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "theme",
|
|
||||||
title: "Theme only",
|
|
||||||
description:
|
|
||||||
"Theme tokens only, like colors, radii, and shadows. Components stay as they are.",
|
|
||||||
flag: "--only theme",
|
|
||||||
label: "--only theme",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "font",
|
|
||||||
title: "Fonts only",
|
|
||||||
description:
|
|
||||||
"Only preset fonts for body and headings. Components stay as they are.",
|
|
||||||
flag: "--only font",
|
|
||||||
label: "--only font",
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
type ApplyMode = (typeof APPLY_MODES)[number]["value"]
|
|
||||||
type ProjectFormTab = "new-project" | "existing-project" | "theme"
|
|
||||||
type CopyTarget = "command" | "apply" | "theme"
|
|
||||||
type ThemeCssVars = NonNullable<
|
|
||||||
ReturnType<typeof buildThemeForPreset>["cssVars"]
|
|
||||||
>
|
|
||||||
|
|
||||||
function formatCssVarsRule(selector: string, cssVars?: Record<string, string>) {
|
|
||||||
const declarations = Object.entries(cssVars ?? {})
|
|
||||||
.map(([key, value]) => ` --${key}: ${value};`)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
return `${selector} {\n${declarations}\n}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatThemeCss(cssVars: ThemeCssVars) {
|
|
||||||
return [
|
|
||||||
formatCssVarsRule(":root", cssVars.light),
|
|
||||||
formatCssVarsRule(".dark", cssVars.dark),
|
|
||||||
].join("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [copiedTarget, setCopiedTarget] = React.useState<CopyTarget | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
const [applyMode, setApplyMode] = React.useState<ApplyMode>("full")
|
|
||||||
const [activeTab, setActiveTab] =
|
|
||||||
React.useState<ProjectFormTab>("new-project")
|
|
||||||
|
|
||||||
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 !== DEFAULT_CONFIG.base ? ` --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]
|
|
||||||
|
|
||||||
const applyCommands = React.useMemo(() => {
|
|
||||||
const presetFlag = ` --preset ${presetCode}`
|
|
||||||
const onlyFlag =
|
|
||||||
applyMode === "theme"
|
|
||||||
? " --only theme"
|
|
||||||
: applyMode === "font"
|
|
||||||
? " --only font"
|
|
||||||
: ""
|
|
||||||
const flags = `${presetFlag}${onlyFlag}`
|
|
||||||
|
|
||||||
return IS_LOCAL_DEV
|
|
||||||
? {
|
|
||||||
pnpm: `shadcn apply${flags}`,
|
|
||||||
npm: `shadcn apply${flags}`,
|
|
||||||
yarn: `shadcn apply${flags}`,
|
|
||||||
bun: `shadcn apply${flags}`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} apply${flags}`,
|
|
||||||
npm: `npx shadcn${SHADCN_VERSION} apply${flags}`,
|
|
||||||
yarn: `yarn dlx shadcn${SHADCN_VERSION} apply${flags}`,
|
|
||||||
bun: `bunx --bun shadcn${SHADCN_VERSION} apply${flags}`,
|
|
||||||
}
|
|
||||||
}, [applyMode, presetCode])
|
|
||||||
|
|
||||||
const applyCommand = applyCommands[packageManager]
|
|
||||||
const themeConfig = React.useMemo<DesignSystemConfig>(() => {
|
|
||||||
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
|
|
||||||
|
|
||||||
return {
|
|
||||||
...DEFAULT_CONFIG,
|
|
||||||
base: params.base,
|
|
||||||
style: params.style,
|
|
||||||
baseColor: params.baseColor,
|
|
||||||
theme: params.theme,
|
|
||||||
chartColor: params.chartColor,
|
|
||||||
iconLibrary: params.iconLibrary,
|
|
||||||
font: params.font,
|
|
||||||
fontHeading: params.fontHeading,
|
|
||||||
menuAccent: params.menuAccent,
|
|
||||||
menuColor: params.menuColor,
|
|
||||||
radius: isRadiusLocked ? "none" : params.radius,
|
|
||||||
template: params.template,
|
|
||||||
rtl: params.rtl,
|
|
||||||
pointer: params.pointer,
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
params.base,
|
|
||||||
params.baseColor,
|
|
||||||
params.chartColor,
|
|
||||||
params.font,
|
|
||||||
params.fontHeading,
|
|
||||||
params.iconLibrary,
|
|
||||||
params.menuAccent,
|
|
||||||
params.menuColor,
|
|
||||||
params.pointer,
|
|
||||||
params.radius,
|
|
||||||
params.rtl,
|
|
||||||
params.style,
|
|
||||||
params.template,
|
|
||||||
params.theme,
|
|
||||||
])
|
|
||||||
|
|
||||||
const themeCss = React.useMemo(() => {
|
|
||||||
const theme = buildThemeForPreset(themeConfig)
|
|
||||||
|
|
||||||
if (!theme.cssVars) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatThemeCss(theme.cssVars)
|
|
||||||
}, [themeConfig])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (copiedTarget) {
|
|
||||||
const timer = setTimeout(() => setCopiedTarget(null), 2000)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [copiedTarget])
|
|
||||||
|
|
||||||
const handleCopy = React.useCallback(() => {
|
|
||||||
const properties: Record<string, string> = {
|
|
||||||
command,
|
|
||||||
}
|
|
||||||
if (params.template) {
|
|
||||||
properties.template = params.template
|
|
||||||
}
|
|
||||||
copyToClipboardWithMeta(command, {
|
|
||||||
name: "copy_npm_command",
|
|
||||||
properties,
|
|
||||||
})
|
|
||||||
setCopiedTarget("command")
|
|
||||||
}, [command, params.template])
|
|
||||||
|
|
||||||
const handleCopyApply = React.useCallback(() => {
|
|
||||||
copyToClipboardWithMeta(applyCommand, {
|
|
||||||
name: "copy_apply_command",
|
|
||||||
properties: {
|
|
||||||
command: applyCommand,
|
|
||||||
applyMode,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
setCopiedTarget("apply")
|
|
||||||
}, [applyCommand, applyMode])
|
|
||||||
|
|
||||||
const handleCopyTheme = React.useCallback(() => {
|
|
||||||
copyToClipboardWithMeta(themeCss, {
|
|
||||||
name: "copy_theme_code",
|
|
||||||
properties: {
|
|
||||||
preset: presetCode,
|
|
||||||
baseColor: params.baseColor,
|
|
||||||
theme: params.theme,
|
|
||||||
format: "css",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
setCopiedTarget("theme")
|
|
||||||
}, [params.baseColor, params.theme, presetCode, themeCss])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger render={<Button className={cn(className)} />}>
|
|
||||||
Get Code
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="dark top-[64px] no-scrollbar flex max-h-[calc(100svh-2rem)] translate-y-0 flex-col rounded-2xl p-0 shadow-xl **:data-[slot=dialog-close]:top-4.5 **:data-[slot=dialog-close]:right-4 **:data-[slot=field-separator]:h-2 sm:max-w-md">
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0 overflow-hidden rounded-2xl">
|
|
||||||
<DialogHeader className="border-b px-6 py-5">
|
|
||||||
<ToggleGroup
|
|
||||||
value={[activeTab]}
|
|
||||||
onValueChange={(values) =>
|
|
||||||
setActiveTab((values[0] as typeof activeTab) ?? "new-project")
|
|
||||||
}
|
|
||||||
aria-label="Project type"
|
|
||||||
spacing={2}
|
|
||||||
className="**:data-[slot=toggle-group-item]:data-pressed:bg-neutral-700/70"
|
|
||||||
>
|
|
||||||
<ToggleGroupItem value="new-project">New Project</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="existing-project">
|
|
||||||
Existing Project
|
|
||||||
</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="theme">Theme</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
|
||||||
</DialogHeader>
|
|
||||||
{activeTab === "new-project" && (
|
|
||||||
<div className="no-scrollbar overflow-y-auto">
|
|
||||||
<FieldGroup className="px-6 py-4">
|
|
||||||
<Field className="gap-3">
|
|
||||||
<FieldLabel>Template</FieldLabel>
|
|
||||||
<TemplateGrid
|
|
||||||
template={params.template}
|
|
||||||
setParams={setParams}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<FieldSeparator className="-mx-6" />
|
|
||||||
<Field>
|
|
||||||
<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>
|
|
||||||
<DialogFooter className="m-0 min-w-0 p-6">
|
|
||||||
<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}
|
|
||||||
>
|
|
||||||
{copiedTarget === "command" ? (
|
|
||||||
<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">
|
|
||||||
{copiedTarget === "command" ? "Copied" : "Copy Command"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTab === "existing-project" && (
|
|
||||||
<div className="no-scrollbar overflow-y-auto">
|
|
||||||
<FieldGroup className="px-6 py-4">
|
|
||||||
<FieldSet className="gap-3">
|
|
||||||
<FieldLegend variant="label">Apply Preset</FieldLegend>
|
|
||||||
<FieldDescription>
|
|
||||||
Pick which parts of the preset to apply.
|
|
||||||
</FieldDescription>
|
|
||||||
<ApplyModeGrid mode={applyMode} setMode={setApplyMode} />
|
|
||||||
</FieldSet>
|
|
||||||
</FieldGroup>
|
|
||||||
<DialogFooter className="m-0 min-w-0 p-6">
|
|
||||||
<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={handleCopyApply}
|
|
||||||
>
|
|
||||||
{copiedTarget === "apply" ? (
|
|
||||||
<HugeiconsIcon icon={Tick02Icon} />
|
|
||||||
) : (
|
|
||||||
<HugeiconsIcon icon={Copy01Icon} />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">Copy command</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{Object.entries(applyCommands).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={handleCopyApply} className="h-9 w-full">
|
|
||||||
{copiedTarget === "apply" ? "Copied" : "Copy Command"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTab === "theme" && (
|
|
||||||
<div className="no-scrollbar overflow-y-auto">
|
|
||||||
<FieldGroup className="min-w-0 px-6 py-4">
|
|
||||||
<FieldSet className="min-w-0 gap-3">
|
|
||||||
<FieldLegend variant="label">Theme Tokens</FieldLegend>
|
|
||||||
<FieldDescription>
|
|
||||||
Copy the CSS variables for this preset.
|
|
||||||
</FieldDescription>
|
|
||||||
<div className="w-full min-w-0 overflow-hidden rounded-xl border-0 ring-1 ring-border">
|
|
||||||
<div className="flex items-center gap-2 py-1 pr-1.5 pl-3">
|
|
||||||
<div className="min-w-0 truncate font-mono text-sm text-muted-foreground">
|
|
||||||
globals.css
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="ml-auto"
|
|
||||||
onClick={handleCopyTheme}
|
|
||||||
>
|
|
||||||
{copiedTarget === "theme" ? (
|
|
||||||
<HugeiconsIcon icon={Tick02Icon} />
|
|
||||||
) : (
|
|
||||||
<HugeiconsIcon icon={Copy01Icon} />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">Copy theme</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="relative no-scrollbar max-h-[45svh] overflow-auto border-t bg-popover p-3">
|
|
||||||
<pre className="min-w-max font-mono leading-normal whitespace-pre">
|
|
||||||
<code>{themeCss}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FieldSet>
|
|
||||||
</FieldGroup>
|
|
||||||
<DialogFooter className="m-0 min-w-0 p-6">
|
|
||||||
<Button onClick={handleCopyTheme} className="h-9 w-full">
|
|
||||||
{copiedTarget === "theme" ? "Copied" : "Copy Theme"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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 ApplyModeGrid = React.memo(function ApplyModeGrid({
|
|
||||||
mode,
|
|
||||||
setMode,
|
|
||||||
}: {
|
|
||||||
mode: ApplyMode
|
|
||||||
setMode: (mode: ApplyMode) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<RadioGroup
|
|
||||||
value={mode}
|
|
||||||
onValueChange={(value) => setMode(value as ApplyMode)}
|
|
||||||
aria-label="Apply"
|
|
||||||
>
|
|
||||||
{APPLY_MODES.map((option) => (
|
|
||||||
<FieldLabel key={option.value} htmlFor={`apply-${option.value}`}>
|
|
||||||
<Field orientation="horizontal">
|
|
||||||
<RadioGroupItem value={option.value} id={`apply-${option.value}`} />
|
|
||||||
<FieldContent>
|
|
||||||
<FieldTitle>{option.title}</FieldTitle>
|
|
||||||
<FieldDescription>{option.description}</FieldDescription>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
</FieldLabel>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const BaseGrid = React.memo(function BaseGrid({
|
|
||||||
base,
|
|
||||||
setParams,
|
|
||||||
}: {
|
|
||||||
base: DesignSystemSearchParams["base"]
|
|
||||||
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
|
|
||||||
}) {
|
|
||||||
const handleBaseChange = React.useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setParams({ base: value as BaseName })
|
|
||||||
},
|
|
||||||
[setParams]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RadioGroup
|
|
||||||
value={base}
|
|
||||||
onValueChange={handleBaseChange}
|
|
||||||
aria-label="Base"
|
|
||||||
className="grid grid-cols-2 gap-2"
|
|
||||||
>
|
|
||||||
{BASES.map((item) => (
|
|
||||||
<FieldLabel
|
|
||||||
key={item.name}
|
|
||||||
htmlFor={`base-${item.name}`}
|
|
||||||
className="block w-full"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
orientation="horizontal"
|
|
||||||
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
|
|
||||||
>
|
|
||||||
<FieldContent className="flex flex-row items-center gap-2 px-2.5 py-1.5">
|
|
||||||
<div
|
|
||||||
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: item.meta?.logo ?? "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FieldTitle>{item.title}</FieldTitle>
|
|
||||||
</FieldContent>
|
|
||||||
<RadioGroupItem
|
|
||||||
value={item.name}
|
|
||||||
id={`base-${item.name}`}
|
|
||||||
className="sr-only absolute"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldLabel>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
|
||||||
import { useMounted } from "@/hooks/use-mounted"
|
|
||||||
import { Icons } from "@/components/icons"
|
|
||||||
import { Button } from "@/styles/base-nova/ui/button"
|
|
||||||
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
|
||||||
|
|
||||||
export function V0Button({ className }: { className?: string }) {
|
|
||||||
const [params] = useDesignSystemSearchParams()
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const isMounted = useMounted()
|
|
||||||
|
|
||||||
const url = React.useMemo(() => {
|
|
||||||
const searchParams = new URLSearchParams()
|
|
||||||
|
|
||||||
if (params.preset) {
|
|
||||||
searchParams.set("preset", params.preset)
|
|
||||||
}
|
|
||||||
|
|
||||||
searchParams.set("base", params.base)
|
|
||||||
|
|
||||||
return `${process.env.NEXT_PUBLIC_APP_URL}/init/v0?${searchParams.toString()}`
|
|
||||||
}, [params.preset, params.base])
|
|
||||||
|
|
||||||
const title = React.useMemo(() => {
|
|
||||||
return params.base && params.style
|
|
||||||
? `New ${params.base}-${params.style} project`
|
|
||||||
: "New Project"
|
|
||||||
}, [params.base, params.style])
|
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
return <Skeleton className="h-8 w-24 rounded-lg" />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
nativeButton={false}
|
|
||||||
role="link"
|
|
||||||
variant={isMobile ? "default" : "outline"}
|
|
||||||
className={cn("h-[31px] gap-1 rounded-lg", className)}
|
|
||||||
render={
|
|
||||||
<a
|
|
||||||
href={`${process.env.NEXT_PUBLIC_V0_URL}/chat/api/open?url=${url}&title=${title}`}
|
|
||||||
target="_blank"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>Open in</span>
|
|
||||||
<Icons.v0 className="size-5" data-icon="inline-end" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Suspense } from "react"
|
|
||||||
|
|
||||||
import { CreateSkeleton } from "@/app/(app)/(create)/components/create-skeleton"
|
|
||||||
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>
|
|
||||||
{/* HistoryProvider reads useSearchParams(), which opts this subtree out
|
|
||||||
of prerendering up to this boundary. The fallback is what paints on
|
|
||||||
first load, so it must be the page placeholder, not empty. */}
|
|
||||||
<Suspense fallback={<CreateSkeleton />}>
|
|
||||||
<HistoryProvider>{children}</HistoryProvider>
|
|
||||||
</Suspense>
|
|
||||||
</LocksProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { CreateSkeleton } from "@/app/(app)/(create)/components/create-skeleton"
|
|
||||||
|
|
||||||
export default function CreateLoading() {
|
|
||||||
return <CreateSkeleton />
|
|
||||||
}
|
|
||||||
@@ -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} />
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
|
||||||
|
|
||||||
// Returns the canonical preset code derived from the current search params.
|
|
||||||
export function usePresetCode() {
|
|
||||||
const [params] = useDesignSystemSearchParams()
|
|
||||||
|
|
||||||
return getPresetCode(params)
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Suspense } from "react"
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
|
|
||||||
import {
|
|
||||||
useDesignSystemSearchParams,
|
|
||||||
type DesignSystemSearchParams,
|
|
||||||
} from "@/app/(app)/(create)/lib/search-params"
|
|
||||||
|
|
||||||
type HistoryContextValue = {
|
|
||||||
canGoBack: boolean
|
|
||||||
canGoForward: boolean
|
|
||||||
goBack: () => void
|
|
||||||
goForward: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const HistoryContext = React.createContext<HistoryContextValue | null>(null)
|
|
||||||
|
|
||||||
// Reads useSearchParams() in its own Suspense boundary so the
|
|
||||||
// provider never blanks out children while search params resolve.
|
|
||||||
// useSearchParams reflects the *settled* preset, which coalesces the
|
|
||||||
// transient values nuqs emits mid-update — reading nuqs state directly
|
|
||||||
// here would record those transients as phantom history entries.
|
|
||||||
function PresetSync({
|
|
||||||
onPresetChange,
|
|
||||||
}: {
|
|
||||||
onPresetChange: (preset: string) => void
|
|
||||||
}) {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const preset = searchParams.get("preset") ?? ""
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
onPresetChange(preset)
|
|
||||||
}, [preset, onPresetChange])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
// Write through the shared search-params hook (shallow) instead of
|
|
||||||
// router.replace. router.replace triggers a full server navigation that
|
|
||||||
// resets the preset on prod — the same failure the customizer avoids by
|
|
||||||
// going shallow (#11060).
|
|
||||||
const [, setParams] = useDesignSystemSearchParams()
|
|
||||||
|
|
||||||
const [preset, setPreset] = React.useState("")
|
|
||||||
|
|
||||||
const entriesRef = React.useRef<string[]>([preset])
|
|
||||||
const indexRef = React.useRef(0)
|
|
||||||
const maxIndexRef = React.useRef(0)
|
|
||||||
const isNavigatingRef = React.useRef(false)
|
|
||||||
|
|
||||||
const [index, setIndex] = React.useState(0)
|
|
||||||
const [maxIndex, setMaxIndex] = React.useState(0)
|
|
||||||
|
|
||||||
const onPresetChange = React.useCallback((nextPreset: string) => {
|
|
||||||
setPreset(nextPreset)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isNavigatingRef.current) {
|
|
||||||
isNavigatingRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preset === entriesRef.current[indexRef.current]) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextEntries = entriesRef.current.slice(0, indexRef.current + 1)
|
|
||||||
nextEntries.push(preset)
|
|
||||||
entriesRef.current = nextEntries
|
|
||||||
|
|
||||||
const nextIndex = nextEntries.length - 1
|
|
||||||
indexRef.current = nextIndex
|
|
||||||
maxIndexRef.current = nextIndex
|
|
||||||
setIndex(nextIndex)
|
|
||||||
setMaxIndex(nextIndex)
|
|
||||||
}, [preset])
|
|
||||||
|
|
||||||
const canGoBack = index > 0
|
|
||||||
const canGoForward = index < maxIndex
|
|
||||||
|
|
||||||
const goBack = React.useCallback(() => {
|
|
||||||
if (indexRef.current <= 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isNavigatingRef.current = true
|
|
||||||
const nextIndex = indexRef.current - 1
|
|
||||||
indexRef.current = nextIndex
|
|
||||||
setIndex(nextIndex)
|
|
||||||
|
|
||||||
// The first history entry is "" (no preset in the URL); null clears the
|
|
||||||
// param to restore that state. nuqs accepts null to clear a key, which the
|
|
||||||
// wrapper's input type does not model.
|
|
||||||
setParams(
|
|
||||||
{
|
|
||||||
preset: entriesRef.current[nextIndex] || null,
|
|
||||||
} as Partial<DesignSystemSearchParams>,
|
|
||||||
{ history: "replace" }
|
|
||||||
)
|
|
||||||
}, [setParams])
|
|
||||||
|
|
||||||
const goForward = React.useCallback(() => {
|
|
||||||
if (indexRef.current >= maxIndexRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isNavigatingRef.current = true
|
|
||||||
const nextIndex = indexRef.current + 1
|
|
||||||
indexRef.current = nextIndex
|
|
||||||
setIndex(nextIndex)
|
|
||||||
|
|
||||||
// The first history entry is "" (no preset in the URL); null clears the
|
|
||||||
// param to restore that state. nuqs accepts null to clear a key, which the
|
|
||||||
// wrapper's input type does not model.
|
|
||||||
setParams(
|
|
||||||
{
|
|
||||||
preset: entriesRef.current[nextIndex] || null,
|
|
||||||
} as Partial<DesignSystemSearchParams>,
|
|
||||||
{ history: "replace" }
|
|
||||||
)
|
|
||||||
}, [setParams])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const down = (e: KeyboardEvent) => {
|
|
||||||
if (!e.metaKey && !e.ctrlKey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
|
||||||
e.target instanceof HTMLInputElement ||
|
|
||||||
e.target instanceof HTMLTextAreaElement ||
|
|
||||||
e.target instanceof HTMLSelectElement
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = e.key.toLowerCase()
|
|
||||||
|
|
||||||
if ((key === "z" && e.shiftKey) || (key === "y" && e.ctrlKey)) {
|
|
||||||
e.preventDefault()
|
|
||||||
goForward()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "z") {
|
|
||||||
e.preventDefault()
|
|
||||||
goBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", down)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", down)
|
|
||||||
}
|
|
||||||
}, [goBack, goForward])
|
|
||||||
|
|
||||||
const value = React.useMemo(
|
|
||||||
() => ({ canGoBack, canGoForward, goBack, goForward }),
|
|
||||||
[canGoBack, canGoForward, goBack, goForward]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HistoryContext value={value}>
|
|
||||||
<Suspense>
|
|
||||||
<PresetSync onPresetChange={onPresetChange} />
|
|
||||||
</Suspense>
|
|
||||||
{children}
|
|
||||||
</HistoryContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useHistory() {
|
|
||||||
const context = React.useContext(HistoryContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useHistory must be used within HistoryProvider")
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import useSWR from "swr"
|
|
||||||
|
|
||||||
const OPEN_PRESET_KEY = "create:open-preset-open"
|
|
||||||
export const OPEN_PRESET_FORWARD_TYPE = "open-preset-forward"
|
|
||||||
|
|
||||||
function isEditableTarget(target: EventTarget | null) {
|
|
||||||
return (
|
|
||||||
(target instanceof HTMLElement && target.isContentEditable) ||
|
|
||||||
target instanceof HTMLInputElement ||
|
|
||||||
target instanceof HTMLTextAreaElement ||
|
|
||||||
target instanceof HTMLSelectElement
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpenPreset() {
|
|
||||||
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
|
|
||||||
OPEN_PRESET_KEY,
|
|
||||||
{
|
|
||||||
fallbackData: false,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
revalidateIfStale: false,
|
|
||||||
revalidateOnReconnect: false,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleOpenChange = React.useCallback(
|
|
||||||
(nextOpen: boolean) => {
|
|
||||||
void setOpenData(nextOpen, { revalidate: false })
|
|
||||||
},
|
|
||||||
[setOpenData]
|
|
||||||
)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const down = (e: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
e.key === "o" &&
|
|
||||||
!e.shiftKey &&
|
|
||||||
!e.metaKey &&
|
|
||||||
!e.ctrlKey &&
|
|
||||||
!e.altKey
|
|
||||||
) {
|
|
||||||
if (isEditableTarget(e.target)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
void setOpenData(true, { revalidate: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", down)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", down)
|
|
||||||
}
|
|
||||||
}, [setOpenData])
|
|
||||||
|
|
||||||
return {
|
|
||||||
open,
|
|
||||||
setOpen: handleOpenChange,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpenPresetTrigger() {
|
|
||||||
const { mutate: setOpenData } = useSWR<boolean>(OPEN_PRESET_KEY, {
|
|
||||||
fallbackData: false,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
revalidateIfStale: false,
|
|
||||||
revalidateOnReconnect: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const openPreset = React.useCallback(() => {
|
|
||||||
void setOpenData(true, { revalidate: false })
|
|
||||||
}, [setOpenData])
|
|
||||||
|
|
||||||
return {
|
|
||||||
openPreset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { decodePreset } from "shadcn/preset"
|
|
||||||
|
|
||||||
import {
|
|
||||||
BASE_COLORS,
|
|
||||||
getThemesForBaseColor,
|
|
||||||
iconLibraries,
|
|
||||||
MENU_ACCENTS,
|
|
||||||
MENU_COLORS,
|
|
||||||
RADII,
|
|
||||||
STYLES,
|
|
||||||
type BaseColorName,
|
|
||||||
type ChartColorName,
|
|
||||||
type FontHeadingValue,
|
|
||||||
type FontValue,
|
|
||||||
type IconLibraryName,
|
|
||||||
type MenuAccentValue,
|
|
||||||
type MenuColorValue,
|
|
||||||
type RadiusValue,
|
|
||||||
type StyleName,
|
|
||||||
type ThemeName,
|
|
||||||
} from "@/registry/config"
|
|
||||||
import { useLocks } from "@/app/(app)/(create)/hooks/use-locks"
|
|
||||||
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
|
|
||||||
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
|
||||||
import {
|
|
||||||
applyBias,
|
|
||||||
RANDOMIZE_BIASES,
|
|
||||||
type RandomizeContext,
|
|
||||||
} from "@/app/(app)/(create)/lib/randomize-biases"
|
|
||||||
import {
|
|
||||||
isTranslucentMenuColor,
|
|
||||||
useDesignSystemSearchParams,
|
|
||||||
} from "@/app/(app)/(create)/lib/search-params"
|
|
||||||
import { SHUFFLE_PRESETS } from "@/app/(app)/(create)/lib/shuffle-presets"
|
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
// True-random implementation. Kept, but shuffle is now wired to the
|
|
||||||
// curated preset list below.
|
|
||||||
const randomizeTrueRandom = 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])
|
|
||||||
|
|
||||||
// Picks a random preset from the curated list instead of true random.
|
|
||||||
// Locked params are preserved over the decoded preset values.
|
|
||||||
const randomize = React.useCallback(() => {
|
|
||||||
// Avoid re-picking the preset that is currently applied.
|
|
||||||
const currentCode = getPresetCode(paramsRef.current)
|
|
||||||
const availableCodes = SHUFFLE_PRESETS.filter(
|
|
||||||
(code) => code !== currentCode
|
|
||||||
)
|
|
||||||
const decoded = decodePreset(
|
|
||||||
randomItem(availableCodes.length > 0 ? availableCodes : SHUFFLE_PRESETS)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!decoded) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = paramsRef.current
|
|
||||||
const nextParams = {
|
|
||||||
style: locks.has("style") ? current.style : (decoded.style as StyleName),
|
|
||||||
baseColor: locks.has("baseColor")
|
|
||||||
? current.baseColor
|
|
||||||
: (decoded.baseColor as BaseColorName),
|
|
||||||
theme: locks.has("theme") ? current.theme : (decoded.theme as ThemeName),
|
|
||||||
chartColor: locks.has("chartColor")
|
|
||||||
? current.chartColor
|
|
||||||
: ((decoded.chartColor ?? decoded.theme) as ChartColorName),
|
|
||||||
iconLibrary: locks.has("iconLibrary")
|
|
||||||
? current.iconLibrary
|
|
||||||
: (decoded.iconLibrary as IconLibraryName),
|
|
||||||
font: locks.has("font") ? current.font : (decoded.font as FontValue),
|
|
||||||
fontHeading: locks.has("fontHeading")
|
|
||||||
? current.fontHeading
|
|
||||||
: (decoded.fontHeading as FontHeadingValue),
|
|
||||||
menuAccent: locks.has("menuAccent")
|
|
||||||
? current.menuAccent
|
|
||||||
: (decoded.menuAccent as MenuAccentValue),
|
|
||||||
menuColor: locks.has("menuColor")
|
|
||||||
? current.menuColor
|
|
||||||
: (decoded.menuColor as MenuColorValue),
|
|
||||||
radius: locks.has("radius")
|
|
||||||
? current.radius
|
|
||||||
: (decoded.radius as RadiusValue),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, randomizeTrueRandom }
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import useSWR from "swr"
|
|
||||||
|
|
||||||
import { DEFAULT_CONFIG, PRESETS } from "@/registry/config"
|
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
|
||||||
|
|
||||||
const RESET_DIALOG_KEY = "create:reset-dialog-open"
|
|
||||||
export const RESET_FORWARD_TYPE = "reset-forward"
|
|
||||||
|
|
||||||
export function useReset() {
|
|
||||||
const [params, setParams] = useDesignSystemSearchParams()
|
|
||||||
const { data: showResetDialog = false, mutate: setShowResetDialogData } =
|
|
||||||
useSWR<boolean>(RESET_DIALOG_KEY, {
|
|
||||||
fallbackData: false,
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
revalidateIfStale: false,
|
|
||||||
revalidateOnReconnect: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const reset = React.useCallback(() => {
|
|
||||||
const preset =
|
|
||||||
PRESETS.find(
|
|
||||||
(preset) => preset.base === params.base && preset.style === params.style
|
|
||||||
) ?? DEFAULT_CONFIG
|
|
||||||
|
|
||||||
setParams({
|
|
||||||
base: params.base,
|
|
||||||
style: params.style,
|
|
||||||
baseColor: preset.baseColor,
|
|
||||||
theme: preset.theme,
|
|
||||||
chartColor: preset.chartColor,
|
|
||||||
iconLibrary: preset.iconLibrary,
|
|
||||||
font: preset.font,
|
|
||||||
fontHeading: preset.fontHeading,
|
|
||||||
menuAccent: preset.menuAccent,
|
|
||||||
menuColor: preset.menuColor,
|
|
||||||
radius: preset.radius,
|
|
||||||
template: DEFAULT_CONFIG.template,
|
|
||||||
item: params.item,
|
|
||||||
})
|
|
||||||
}, [setParams, params.base, params.style, params.item])
|
|
||||||
|
|
||||||
const handleShowResetDialogChange = React.useCallback(
|
|
||||||
(open: boolean) => {
|
|
||||||
void setShowResetDialogData(open, { revalidate: false })
|
|
||||||
},
|
|
||||||
[setShowResetDialogData]
|
|
||||||
)
|
|
||||||
|
|
||||||
const confirmReset = React.useCallback(() => {
|
|
||||||
reset()
|
|
||||||
void setShowResetDialogData(false, { revalidate: false })
|
|
||||||
}, [reset, setShowResetDialogData])
|
|
||||||
|
|
||||||
const showResetDialogRef = React.useRef(showResetDialog)
|
|
||||||
React.useEffect(() => {
|
|
||||||
showResetDialogRef.current = showResetDialog
|
|
||||||
}, [showResetDialog])
|
|
||||||
|
|
||||||
const confirmResetRef = React.useRef(confirmReset)
|
|
||||||
React.useEffect(() => {
|
|
||||||
confirmResetRef.current = confirmReset
|
|
||||||
}, [confirmReset])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const down = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "R" && e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
|
||||||
if (
|
|
||||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
|
||||||
e.target instanceof HTMLInputElement ||
|
|
||||||
e.target instanceof HTMLTextAreaElement ||
|
|
||||||
e.target instanceof HTMLSelectElement
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
// If the dialog is already open, confirm the reset.
|
|
||||||
if (showResetDialogRef.current) {
|
|
||||||
confirmResetRef.current()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
handleShowResetDialogChange(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", down)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", down)
|
|
||||||
}
|
|
||||||
}, [handleShowResetDialogChange])
|
|
||||||
|
|
||||||
return {
|
|
||||||
reset,
|
|
||||||
showResetDialog,
|
|
||||||
setShowResetDialog: handleShowResetDialogChange,
|
|
||||||
confirmReset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { type NextRequest } from "next/server"
|
|
||||||
import { track } from "@vercel/analytics/server"
|
|
||||||
|
|
||||||
import { buildInstructions } from "@/app/(app)/(create)/lib/build-instructions"
|
|
||||||
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const searchParams = request.nextUrl.searchParams
|
|
||||||
const result = parseDesignSystemConfig(searchParams)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return new Response(result.error, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
track("create_app_manual", result.data)
|
|
||||||
|
|
||||||
const markdown = buildInstructions(result.data)
|
|
||||||
|
|
||||||
return new Response(markdown, {
|
|
||||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
return new Response(
|
|
||||||
error instanceof Error ? error.message : "An unknown error occurred",
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildRegistryBase,
|
|
||||||
DEFAULT_CONFIG,
|
|
||||||
POINTER_CURSOR_SELECTOR,
|
|
||||||
} from "@/registry/config"
|
|
||||||
|
|
||||||
import { GET } from "./route"
|
|
||||||
|
|
||||||
function createRequest(search = "") {
|
|
||||||
const searchParams = new URLSearchParams(
|
|
||||||
Object.entries(DEFAULT_CONFIG).map(([key, value]) => [key, String(value)])
|
|
||||||
)
|
|
||||||
const url = new URL(`http://localhost:4000/init${search}`)
|
|
||||||
|
|
||||||
for (const [key, value] of url.searchParams) {
|
|
||||||
searchParams.set(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nextUrl: new URL(`http://localhost:4000/init?${searchParams}`),
|
|
||||||
} as Parameters<typeof GET>[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("GET /init", () => {
|
|
||||||
it("returns the full registry base when only is omitted", async () => {
|
|
||||||
const response = await GET(createRequest())
|
|
||||||
const json = await response.json()
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(json).toEqual(buildRegistryBase(DEFAULT_CONFIG))
|
|
||||||
expect(json.css["@layer base"][POINTER_CURSOR_SELECTOR]).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns pointer cursor css when pointer is enabled", async () => {
|
|
||||||
const response = await GET(createRequest("?pointer=true"))
|
|
||||||
const json = await response.json()
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(json.css["@layer base"][POINTER_CURSOR_SELECTOR]).toEqual({
|
|
||||||
cursor: "pointer",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns a sparse registry base when only is provided", async () => {
|
|
||||||
const response = await GET(createRequest("?only=theme"))
|
|
||||||
const json = await response.json()
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(json.type).toBe("registry:base")
|
|
||||||
expect(json.config).toEqual({
|
|
||||||
menuColor: "default",
|
|
||||||
menuAccent: "subtle",
|
|
||||||
tailwind: {
|
|
||||||
baseColor: "neutral",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(json.cssVars.light).toBeDefined()
|
|
||||||
expect(json.cssVars.light.radius).toBe("0.625rem")
|
|
||||||
expect(json.dependencies).toBeUndefined()
|
|
||||||
expect(json.registryDependencies).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects unsupported only values", async () => {
|
|
||||||
const response = await GET(createRequest("?only=icon"))
|
|
||||||
const json = await response.json()
|
|
||||||
|
|
||||||
expect(response.status).toBe(400)
|
|
||||||
expect(json.error).toBe(
|
|
||||||
"Invalid only value. Use one or more of: theme, font"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server"
|
|
||||||
import { track } from "@vercel/analytics/server"
|
|
||||||
import { isPresetCode } from "shadcn/preset"
|
|
||||||
import { registryItemSchema } from "shadcn/schema"
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildPartialRegistryBase,
|
|
||||||
buildRegistryBase,
|
|
||||||
parseRegistryBaseParts,
|
|
||||||
} from "@/registry/config"
|
|
||||||
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
|
|
||||||
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const searchParams = request.nextUrl.searchParams
|
|
||||||
const result = parseDesignSystemConfig(searchParams)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onlyResult = parseRegistryBaseParts(searchParams.get("only"))
|
|
||||||
if (!onlyResult.success) {
|
|
||||||
return NextResponse.json({ error: onlyResult.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPreset = searchParams.get("preset")
|
|
||||||
const presetCode =
|
|
||||||
rawPreset && isPresetCode(rawPreset)
|
|
||||||
? rawPreset
|
|
||||||
: getPresetCode(result.data)
|
|
||||||
|
|
||||||
const registryBase = onlyResult.parts
|
|
||||||
? buildPartialRegistryBase(result.data, onlyResult.parts)
|
|
||||||
: buildRegistryBase(result.data)
|
|
||||||
const parseResult = registryItemSchema.safeParse(registryBase)
|
|
||||||
|
|
||||||
if (!parseResult.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "Invalid registry base item",
|
|
||||||
details: parseResult.error.format(),
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchParams.get("track") === "1") {
|
|
||||||
track("create_app", {
|
|
||||||
...result.data,
|
|
||||||
preset: presetCode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(parseResult.data)
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "An unknown error occurred",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { after, NextResponse, type NextRequest } from "next/server"
|
|
||||||
import { track } from "@vercel/analytics/server"
|
|
||||||
import { isPresetCode } from "shadcn/preset"
|
|
||||||
|
|
||||||
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
|
|
||||||
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
|
||||||
import { buildV0Payload } from "@/app/(app)/(create)/lib/v0"
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const searchParams = request.nextUrl.searchParams
|
|
||||||
const result = parseDesignSystemConfig(searchParams)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPreset = searchParams.get("preset")
|
|
||||||
const presetCode =
|
|
||||||
rawPreset && isPresetCode(rawPreset)
|
|
||||||
? rawPreset
|
|
||||||
: getPresetCode(result.data)
|
|
||||||
|
|
||||||
// Defer analytics to after response is sent.
|
|
||||||
after(() => {
|
|
||||||
track("create_open_in_v0", {
|
|
||||||
...result.data,
|
|
||||||
preset: presetCode,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const payload = await buildV0Payload(result.data)
|
|
||||||
|
|
||||||
return NextResponse.json(payload)
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "An unknown error occurred",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
import dedent from "dedent"
|
|
||||||
|
|
||||||
import { UI_COMPONENTS } from "@/lib/components"
|
|
||||||
import {
|
|
||||||
buildRegistryBase,
|
|
||||||
getBodyFont,
|
|
||||||
getHeadingFont,
|
|
||||||
getInheritedHeadingFontValue,
|
|
||||||
type DesignSystemConfig,
|
|
||||||
} from "@/registry/config"
|
|
||||||
|
|
||||||
// Builds step-by-step markdown instructions for manually setting up a project.
|
|
||||||
export function buildInstructions(config: DesignSystemConfig) {
|
|
||||||
const registryBase = buildRegistryBase(config)
|
|
||||||
const normalizedFontHeading =
|
|
||||||
config.fontHeading === config.font ? "inherit" : config.fontHeading
|
|
||||||
|
|
||||||
const dependencies = [
|
|
||||||
...(registryBase.dependencies ?? []),
|
|
||||||
"clsx",
|
|
||||||
"tailwind-merge",
|
|
||||||
]
|
|
||||||
|
|
||||||
const lightVars = Object.entries(registryBase.cssVars?.light ?? {})
|
|
||||||
.map(([key, value]) => ` --${key}: ${value};`)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
const darkVars = Object.entries(registryBase.cssVars?.dark ?? {})
|
|
||||||
.map(([key, value]) => ` --${key}: ${value};`)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
const font = getBodyFont(config.font)
|
|
||||||
const headingFont =
|
|
||||||
normalizedFontHeading === "inherit"
|
|
||||||
? undefined
|
|
||||||
: getHeadingFont(normalizedFontHeading)
|
|
||||||
|
|
||||||
const sections = [
|
|
||||||
buildDependenciesSection(dependencies),
|
|
||||||
buildUtilsSection(),
|
|
||||||
buildCssSection(
|
|
||||||
lightVars,
|
|
||||||
darkVars,
|
|
||||||
normalizedFontHeading === "inherit"
|
|
||||||
? getInheritedHeadingFontValue(config.font)
|
|
||||||
: "var(--font-heading)"
|
|
||||||
),
|
|
||||||
buildFontSection(font, headingFont),
|
|
||||||
buildComponentsJsonSection(config),
|
|
||||||
buildAvailableComponentsSection(config),
|
|
||||||
config.rtl ? buildRtlSection(config) : null,
|
|
||||||
]
|
|
||||||
|
|
||||||
return sections.filter(Boolean).join("\n\n---\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDependenciesSection(dependencies: string[]) {
|
|
||||||
const list = dependencies.map((dep) => `- ${dep}`).join("\n")
|
|
||||||
|
|
||||||
return dedent`
|
|
||||||
## Step 1: Dependencies
|
|
||||||
|
|
||||||
The following dependencies are required:
|
|
||||||
|
|
||||||
${list}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUtilsSection() {
|
|
||||||
return dedent`
|
|
||||||
## Step 2: Create \`lib/utils.ts\`
|
|
||||||
|
|
||||||
\`\`\`ts
|
|
||||||
import { clsx, type ClassValue } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
\`\`\`
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCssSection(
|
|
||||||
lightVars: string,
|
|
||||||
darkVars: string,
|
|
||||||
fontHeadingValue: string
|
|
||||||
) {
|
|
||||||
return dedent`
|
|
||||||
## Step 3: Set up CSS
|
|
||||||
|
|
||||||
Add the following to your global CSS file (e.g. \`app/globals.css\`):
|
|
||||||
|
|
||||||
\`\`\`css
|
|
||||||
@import "tailwindcss";
|
|
||||||
@import "tw-animate-css";
|
|
||||||
@import "shadcn/tailwind.css";
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--font-sans: var(--font-sans);
|
|
||||||
--font-heading: ${fontHeadingValue};
|
|
||||||
--font-mono: var(--font-mono);
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
${lightVars}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
${darkVars}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
\`\`\`
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFontSection(
|
|
||||||
font: ReturnType<typeof getBodyFont>,
|
|
||||||
headingFont: ReturnType<typeof getHeadingFont>
|
|
||||||
) {
|
|
||||||
if (!font) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const googleFontsUrl = `https://fonts.google.com/specimen/${font.font.import.replace(/_/g, "+")}`
|
|
||||||
const headingGoogleFontsUrl = headingFont
|
|
||||||
? `https://fonts.google.com/specimen/${headingFont.font.import.replace(/_/g, "+")}`
|
|
||||||
: null
|
|
||||||
const nextImports = headingFont
|
|
||||||
? `${font.font.import}, ${headingFont.font.import}`
|
|
||||||
: font.font.import
|
|
||||||
const nextDeclarations = [
|
|
||||||
`const fontSans = ${font.font.import}({`,
|
|
||||||
` subsets: ["latin"],`,
|
|
||||||
` variable: "${font.font.variable}",`,
|
|
||||||
`})`,
|
|
||||||
headingFont ? `const fontHeading = ${headingFont.font.import}({` : null,
|
|
||||||
headingFont ? ` subsets: ["latin"],` : null,
|
|
||||||
headingFont ? ` variable: "${headingFont.font.variable}",` : null,
|
|
||||||
headingFont ? `})` : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
const nextHtmlClassName = headingFont
|
|
||||||
? 'className={fontSans.variable + " " + fontHeading.variable}'
|
|
||||||
: `className={fontSans.variable}`
|
|
||||||
const otherFrameworkCss = [
|
|
||||||
":root {",
|
|
||||||
` ${font.font.variable}: ${font.font.family};`,
|
|
||||||
...(headingFont
|
|
||||||
? [` ${headingFont.font.variable}: ${headingFont.font.family};`]
|
|
||||||
: []),
|
|
||||||
"}",
|
|
||||||
].join("\n")
|
|
||||||
const headingSection = headingFont
|
|
||||||
? dedent`
|
|
||||||
|
|
||||||
This config also uses **${headingFont.title.replace(" (Heading)", "")}** for headings (\`${headingFont.font.variable}\`).
|
|
||||||
`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
return dedent`
|
|
||||||
## Step 4: Set up the font
|
|
||||||
|
|
||||||
This config uses **${font.title}** (\`${font.font.variable}\`).
|
|
||||||
${headingSection}
|
|
||||||
|
|
||||||
### Next.js
|
|
||||||
|
|
||||||
\`\`\`tsx
|
|
||||||
import { ${nextImports} } from "next/font/google"
|
|
||||||
|
|
||||||
${nextDeclarations}
|
|
||||||
|
|
||||||
// Add the font variable classes to your <html> className.
|
|
||||||
// <html ${nextHtmlClassName}>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Other frameworks
|
|
||||||
|
|
||||||
Add the font${headingFont ? "s" : ""} from [Google Fonts](${googleFontsUrl})${headingGoogleFontsUrl ? ` and [Google Fonts](${headingGoogleFontsUrl})` : ""} and set the CSS variable${headingFont ? "s" : ""} to the font famil${headingFont ? "ies" : "y"}:
|
|
||||||
|
|
||||||
\`\`\`css
|
|
||||||
${otherFrameworkCss}
|
|
||||||
\`\`\`
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildComponentsJsonSection(config: DesignSystemConfig) {
|
|
||||||
const componentsJson = {
|
|
||||||
$schema: "https://ui.shadcn.com/schema.json",
|
|
||||||
style: `${config.base}-${config.style}`,
|
|
||||||
tailwind: {
|
|
||||||
css: "app/globals.css",
|
|
||||||
baseColor: config.baseColor,
|
|
||||||
},
|
|
||||||
iconLibrary: config.iconLibrary,
|
|
||||||
aliases: {
|
|
||||||
components: "@/components",
|
|
||||||
utils: "@/lib/utils",
|
|
||||||
ui: "@/components/ui",
|
|
||||||
lib: "@/lib",
|
|
||||||
hooks: "@/hooks",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return dedent`
|
|
||||||
## Step 5: Create \`components.json\`
|
|
||||||
|
|
||||||
Add a \`components.json\` file to the root of your project:
|
|
||||||
|
|
||||||
\`\`\`json
|
|
||||||
${JSON.stringify(componentsJson, null, 2)}
|
|
||||||
\`\`\`
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAvailableComponentsSection(config: DesignSystemConfig) {
|
|
||||||
const list = UI_COMPONENTS.join(", ")
|
|
||||||
const style = `${config.base}-${config.style}`
|
|
||||||
|
|
||||||
return dedent`
|
|
||||||
## Available Components
|
|
||||||
|
|
||||||
${list}
|
|
||||||
|
|
||||||
To fetch the source for a component, use:
|
|
||||||
\`https://ui.shadcn.com/r/styles/${style}/<component>.json\`
|
|
||||||
|
|
||||||
For documentation and examples, visit:
|
|
||||||
\`https://ui.shadcn.com/docs/components/${config.base}/<component>\`
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRtlSection(config: DesignSystemConfig) {
|
|
||||||
const template =
|
|
||||||
config.template === "next-monorepo" ? "next" : (config.template ?? "next")
|
|
||||||
|
|
||||||
return dedent`
|
|
||||||
## RTL Support
|
|
||||||
|
|
||||||
Add \`dir="rtl"\` to your root \`<html>\` element:
|
|
||||||
|
|
||||||
\`\`\`html
|
|
||||||
<html dir="rtl">
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
For full RTL setup including the \`DirectionProvider\`, see the [RTL documentation](https://ui.shadcn.com/docs/rtl/${template}).
|
|
||||||
`
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { type BaseName } from "@/registry/config"
|
|
||||||
|
|
||||||
export function getDocsPathForItem(base: BaseName, item: string) {
|
|
||||||
if (item.endsWith("-example")) {
|
|
||||||
const component = item.slice(0, -"-example".length)
|
|
||||||
return `/docs/components/${base}/${component}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/docs/components"
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { FONT_DEFINITIONS, type FontName } from "@/lib/font-definitions"
|
|
||||||
|
|
||||||
type CreateFont = {
|
|
||||||
family: string
|
|
||||||
import: string
|
|
||||||
previewVariable: string
|
|
||||||
style: {
|
|
||||||
fontFamily: string
|
|
||||||
}
|
|
||||||
variable: string
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
|
||||||
family: definition.family,
|
|
||||||
import: definition.import,
|
|
||||||
previewVariable: definition.previewVariable,
|
|
||||||
style: {
|
|
||||||
fontFamily: `var(${definition.previewVariable}), ${definition.family}`,
|
|
||||||
},
|
|
||||||
variable: definition.registryVariable,
|
|
||||||
} satisfies CreateFont,
|
|
||||||
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]
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
import { parseDesignSystemConfig } from "./parse-config"
|
|
||||||
|
|
||||||
describe("parseDesignSystemConfig", () => {
|
|
||||||
it("defaults pointer to false when omitted", () => {
|
|
||||||
const result = parseDesignSystemConfig(
|
|
||||||
new URLSearchParams(
|
|
||||||
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(result.data.pointer).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("parses pointer=true", () => {
|
|
||||||
const result = parseDesignSystemConfig(
|
|
||||||
new URLSearchParams(
|
|
||||||
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&pointer=true&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(result.data.pointer).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("defaults missing chartColor from the selected theme", () => {
|
|
||||||
const result = parseDesignSystemConfig(
|
|
||||||
new URLSearchParams(
|
|
||||||
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(result.data.chartColor).toBe("taupe")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("honors explicit fontHeading and chartColor overrides when a preset is present", () => {
|
|
||||||
const result = parseDesignSystemConfig(
|
|
||||||
new URLSearchParams(
|
|
||||||
"preset=a0&fontHeading=playfair-display&chartColor=emerald"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(result.data.fontHeading).toBe("playfair-display")
|
|
||||||
expect(result.data.chartColor).toBe("emerald")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps pointer outside preset decoding", () => {
|
|
||||||
const result = parseDesignSystemConfig(
|
|
||||||
new URLSearchParams("preset=a0&pointer=true")
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(result.data.pointer).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
import { parsePresetInput } from "./parse-preset-input"
|
|
||||||
|
|
||||||
describe("parsePresetInput", () => {
|
|
||||||
it("accepts a raw preset code", () => {
|
|
||||||
expect(parsePresetInput("b0")).toBe("b0")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts a --preset flag", () => {
|
|
||||||
expect(parsePresetInput(" --preset b0 ")).toBe("b0")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects invalid preset input", () => {
|
|
||||||
expect(parsePresetInput("open sesame")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { isPresetCode } from "shadcn/preset"
|
|
||||||
|
|
||||||
const PRESET_FLAG_PATTERN = /^--preset\b\s+(.+)$/i
|
|
||||||
|
|
||||||
export function parsePresetInput(value: string) {
|
|
||||||
const input = value.trim()
|
|
||||||
|
|
||||||
if (!input) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = input.match(PRESET_FLAG_PATTERN)?.[1]?.trim() ?? input
|
|
||||||
|
|
||||||
return isPresetCode(preset) ? preset : null
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { encodePreset, type PresetConfig } from "shadcn/preset"
|
|
||||||
|
|
||||||
import { type DesignSystemConfig } from "@/registry/config"
|
|
||||||
|
|
||||||
type PresetCodeConfig = Pick<
|
|
||||||
DesignSystemConfig,
|
|
||||||
| "style"
|
|
||||||
| "baseColor"
|
|
||||||
| "theme"
|
|
||||||
| "chartColor"
|
|
||||||
| "iconLibrary"
|
|
||||||
| "font"
|
|
||||||
| "fontHeading"
|
|
||||||
| "radius"
|
|
||||||
| "menuAccent"
|
|
||||||
| "menuColor"
|
|
||||||
>
|
|
||||||
|
|
||||||
export function getPresetCode(config: PresetCodeConfig) {
|
|
||||||
const presetConfig: Partial<PresetConfig> = {
|
|
||||||
style: config.style as PresetConfig["style"],
|
|
||||||
baseColor: config.baseColor as PresetConfig["baseColor"],
|
|
||||||
theme: config.theme as PresetConfig["theme"],
|
|
||||||
chartColor: config.chartColor as PresetConfig["chartColor"],
|
|
||||||
iconLibrary: config.iconLibrary as PresetConfig["iconLibrary"],
|
|
||||||
font: config.font as PresetConfig["font"],
|
|
||||||
fontHeading: config.fontHeading as PresetConfig["fontHeading"],
|
|
||||||
radius: config.radius as PresetConfig["radius"],
|
|
||||||
menuAccent: config.menuAccent as PresetConfig["menuAccent"],
|
|
||||||
menuColor: config.menuColor as PresetConfig["menuColor"],
|
|
||||||
}
|
|
||||||
|
|
||||||
return encodePreset(presetConfig)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
import { resolvePresetOverrides } from "./preset-query"
|
|
||||||
|
|
||||||
describe("resolvePresetOverrides", () => {
|
|
||||||
it("prefers explicit fontHeading and chartColor query params", () => {
|
|
||||||
const overrides = resolvePresetOverrides(
|
|
||||||
new URLSearchParams("fontHeading=playfair-display&chartColor=emerald"),
|
|
||||||
{
|
|
||||||
theme: "neutral",
|
|
||||||
chartColor: "blue",
|
|
||||||
fontHeading: "inherit",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(overrides).toEqual({
|
|
||||||
fontHeading: "playfair-display",
|
|
||||||
chartColor: "emerald",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("falls back to decoded preset values when no overrides are present", () => {
|
|
||||||
const overrides = resolvePresetOverrides(new URLSearchParams(), {
|
|
||||||
theme: "neutral",
|
|
||||||
chartColor: "blue",
|
|
||||||
fontHeading: "inherit",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(overrides).toEqual({
|
|
||||||
fontHeading: "inherit",
|
|
||||||
chartColor: "blue",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { V1_CHART_COLOR_MAP, type PresetConfig } from "shadcn/preset"
|
|
||||||
|
|
||||||
import { type ChartColorName, type FontHeadingValue } from "@/registry/config"
|
|
||||||
|
|
||||||
type SearchParamsLike = Pick<URLSearchParams, "get" | "has">
|
|
||||||
|
|
||||||
export function resolvePresetOverrides(
|
|
||||||
searchParams: SearchParamsLike,
|
|
||||||
decoded: Pick<PresetConfig, "theme" | "chartColor" | "fontHeading">
|
|
||||||
) {
|
|
||||||
const hasFontHeadingOverride = searchParams.has("fontHeading")
|
|
||||||
const hasChartColorOverride = searchParams.has("chartColor")
|
|
||||||
|
|
||||||
const fontHeading = hasFontHeadingOverride
|
|
||||||
? ((searchParams.get("fontHeading") ??
|
|
||||||
decoded.fontHeading) as FontHeadingValue)
|
|
||||||
: decoded.fontHeading
|
|
||||||
|
|
||||||
const chartColor = hasChartColorOverride
|
|
||||||
? ((searchParams.get("chartColor") ??
|
|
||||||
decoded.chartColor ??
|
|
||||||
V1_CHART_COLOR_MAP[decoded.theme] ??
|
|
||||||
decoded.theme) as ChartColorName)
|
|
||||||
: (decoded.chartColor ?? V1_CHART_COLOR_MAP[decoded.theme] ?? decoded.theme)
|
|
||||||
|
|
||||||
return {
|
|
||||||
fontHeading,
|
|
||||||
chartColor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rhea does not support the "large" radius.
|
|
||||||
if (context.style === "rhea") {
|
|
||||||
return radii.filter((radius) => radius.name !== "large")
|
|
||||||
}
|
|
||||||
|
|
||||||
return radii
|
|
||||||
},
|
|
||||||
chartColors: (chartColors, context) => {
|
|
||||||
// When theme has a pairing, restrict chart colors to the paired values.
|
|
||||||
const pairing = context.theme ? CHART_COLOR_PAIRINGS[context.theme] : null
|
|
||||||
if (pairing) {
|
|
||||||
const filtered = chartColors.filter((c) => pairing.includes(c.name))
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chartColors
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies biases to a list of items based on the current context.
|
|
||||||
*/
|
|
||||||
export function applyBias<T>(
|
|
||||||
items: readonly T[],
|
|
||||||
context: RandomizeContext,
|
|
||||||
biasFilter?: BiasFilter<T>
|
|
||||||
): readonly T[] {
|
|
||||||
if (!biasFilter) {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
return biasFilter(items, context)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
import { DEFAULT_CONFIG } from "@/registry/config"
|
|
||||||
|
|
||||||
import { buildPresetUrlUpdate } from "./search-params"
|
|
||||||
|
|
||||||
describe("buildPresetUrlUpdate", () => {
|
|
||||||
it("encodes design system params into preset and clears individual keys", () => {
|
|
||||||
const update = buildPresetUrlUpdate({
|
|
||||||
...DEFAULT_CONFIG,
|
|
||||||
preset: "b0",
|
|
||||||
template: "next",
|
|
||||||
item: "preview-02",
|
|
||||||
size: 100,
|
|
||||||
custom: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(update.preset).toBeTypeOf("string")
|
|
||||||
expect(update.style).toBeNull()
|
|
||||||
expect(update.theme).toBeNull()
|
|
||||||
expect(update.baseColor).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("preserves template when syncing preset from ?template=start", () => {
|
|
||||||
const update = buildPresetUrlUpdate({
|
|
||||||
...DEFAULT_CONFIG,
|
|
||||||
preset: "b0",
|
|
||||||
template: "start",
|
|
||||||
item: "preview-02",
|
|
||||||
size: 100,
|
|
||||||
custom: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(update.template).toBe("start")
|
|
||||||
expect(update.preset).toBeTypeOf("string")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("applies non-design-system updates from resolvedUpdates", () => {
|
|
||||||
const update = buildPresetUrlUpdate(
|
|
||||||
{
|
|
||||||
...DEFAULT_CONFIG,
|
|
||||||
preset: "b0",
|
|
||||||
template: "next",
|
|
||||||
item: "preview-02",
|
|
||||||
size: 100,
|
|
||||||
custom: false,
|
|
||||||
},
|
|
||||||
{ template: "vite" }
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(update.template).toBe("vite")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,343 +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
|
|
||||||
|
|
||||||
// Shared across hook instances so only one mount effect encodes the initial URL.
|
|
||||||
let hasSyncedPresetToUrl = false
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPresetUrlUpdate(
|
|
||||||
merged: DesignSystemSearchParams,
|
|
||||||
resolvedUpdates: Partial<DesignSystemSearchParams> = {}
|
|
||||||
) {
|
|
||||||
const code = getPresetCode(merged)
|
|
||||||
const rawUpdate: Record<string, unknown> = { preset: code }
|
|
||||||
|
|
||||||
for (const key of DESIGN_SYSTEM_KEYS) {
|
|
||||||
rawUpdate[key] = null
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of NON_DESIGN_SYSTEM_KEYS) {
|
|
||||||
if (key === "preset") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rawUpdate[key] =
|
|
||||||
key in resolvedUpdates
|
|
||||||
? (resolvedUpdates as Record<string, unknown>)[key]
|
|
||||||
: merged[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawUpdate
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// Default options use shallow: true so picker selections do not trigger a full
|
|
||||||
// Next.js server navigation. This prevents the customizer panel from flickering
|
|
||||||
// or resetting while the URL update propagates (#10910, #11060).
|
|
||||||
export function useDesignSystemSearchParams(options: Options = {}) {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const [rawParams, rawSetParams] = useQueryStates(designSystemSearchParams, {
|
|
||||||
shallow: true,
|
|
||||||
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])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (hasSyncedPresetToUrl || searchParams.has("preset")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSyncedPresetToUrl = true
|
|
||||||
|
|
||||||
const merged = normalizeDesignSystemParams(paramsRef.current)
|
|
||||||
void rawSetParams(buildPresetUrlUpdate(merged) as RawSetParamsInput, {
|
|
||||||
history: "replace",
|
|
||||||
})
|
|
||||||
}, [rawSetParams, searchParams])
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
return rawSetParams(
|
|
||||||
buildPresetUrlUpdate(merged, resolvedUpdates) as RawSetParamsInput,
|
|
||||||
setOptions
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[rawSetParams]
|
|
||||||
)
|
|
||||||
|
|
||||||
return [params, setParams] as const
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// Curated preset codes from https://dialectcn.xyz.
|
|
||||||
export const SHUFFLE_PRESETS = [
|
|
||||||
"b6sUj34d9",
|
|
||||||
"b2tqYzpa88",
|
|
||||||
"b1W4tDrk",
|
|
||||||
"b1aIuQ2XC",
|
|
||||||
"b7jsW1RxJ5",
|
|
||||||
"b870VEw0in",
|
|
||||||
"b3Zheoix4U",
|
|
||||||
"b1x9M2c4aI",
|
|
||||||
"b1W7jDEW",
|
|
||||||
"b51GFh7y6",
|
|
||||||
"b2fms620zo",
|
|
||||||
"b1Q5GC",
|
|
||||||
"buKEvLs",
|
|
||||||
"b5rR41Mtnc",
|
|
||||||
"b6tOz2I0x",
|
|
||||||
"b2hNTREGRN",
|
|
||||||
"bdIJ7Sq",
|
|
||||||
"b6TqMNb5Wb",
|
|
||||||
"bJIirQ",
|
|
||||||
"b4aRK5K0fb",
|
|
||||||
"b5HCiD38LI",
|
|
||||||
"bdHjvCi",
|
|
||||||
"b7QDHijUjj",
|
|
||||||
"b4ZVZIPi9h",
|
|
||||||
"b1W4bcno",
|
|
||||||
]
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
||||||
|
|
||||||
import { DEFAULT_CONFIG } from "@/registry/config"
|
|
||||||
import { buildV0Payload } from "@/app/(app)/(create)/lib/v0"
|
|
||||||
|
|
||||||
vi.mock("shadcn/schema", async () => {
|
|
||||||
return await vi.importActual("shadcn/schema")
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock("shadcn/utils", async () => {
|
|
||||||
const actual = (await vi.importActual("shadcn/utils")) as {
|
|
||||||
transformFont: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
transformFont: actual.transformFont,
|
|
||||||
transformIcons: async ({ sourceFile }: { sourceFile: unknown }) =>
|
|
||||||
sourceFile,
|
|
||||||
transformMenu: async ({ sourceFile }: { sourceFile: unknown }) =>
|
|
||||||
sourceFile,
|
|
||||||
transformRender: async ({ sourceFile }: { sourceFile: unknown }) =>
|
|
||||||
sourceFile,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock("@/registry/bases/__index__", () => ({
|
|
||||||
Index: {
|
|
||||||
base: {
|
|
||||||
card: {
|
|
||||||
name: "card",
|
|
||||||
type: "registry:ui",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
radix: {
|
|
||||||
card: {
|
|
||||||
name: "card",
|
|
||||||
type: "registry:ui",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe("buildV0Payload", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.NEXT_PUBLIC_APP_URL = "http://example.test"
|
|
||||||
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn(async (input: string | URL | Request) => {
|
|
||||||
const url =
|
|
||||||
typeof input === "string"
|
|
||||||
? input
|
|
||||||
: input instanceof URL
|
|
||||||
? input.toString()
|
|
||||||
: input.url
|
|
||||||
const name = url.split("/").pop()?.replace(".json", "") ?? "component"
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
name,
|
|
||||||
type: "registry:ui",
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
path: `registry/base-nova/ui/${name}.tsx`,
|
|
||||||
type: "registry:ui",
|
|
||||||
content: `import * as React from "react"\n\nexport function Component() {\n return <div className="cn-font-heading text-xl" />\n}\n`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
delete process.env.NEXT_PUBLIC_APP_URL
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rewrites cn-font-heading to font-heading when heading inherits the body font", async () => {
|
|
||||||
const payload = await buildV0Payload({
|
|
||||||
...DEFAULT_CONFIG,
|
|
||||||
item: undefined,
|
|
||||||
fontHeading: "inherit",
|
|
||||||
})
|
|
||||||
|
|
||||||
const cardFile = payload.files?.find(
|
|
||||||
(file) => file.target === "components/ui/card.tsx"
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(cardFile?.content).toContain("font-heading")
|
|
||||||
expect(cardFile?.content).not.toContain("cn-font-heading")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rewrites cn-font-heading to font-heading when a distinct heading font is selected", async () => {
|
|
||||||
const payload = await buildV0Payload({
|
|
||||||
...DEFAULT_CONFIG,
|
|
||||||
item: undefined,
|
|
||||||
fontHeading: "playfair-display",
|
|
||||||
})
|
|
||||||
|
|
||||||
const cardFile = payload.files?.find(
|
|
||||||
(file) => file.target === "components/ui/card.tsx"
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(cardFile?.content).toContain("font-heading")
|
|
||||||
expect(cardFile?.content).not.toContain("cn-font-heading")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertCircleIcon,
|
|
||||||
ArrowRight01Icon,
|
|
||||||
SquareLock02Icon,
|
|
||||||
} from "@hugeicons/core-free-icons"
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
|
||||||
|
|
||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/styles/base-rhea/ui/field"
|
|
||||||
import { Input } from "@/styles/base-rhea/ui/input"
|
|
||||||
import {
|
|
||||||
Item,
|
|
||||||
ItemContent,
|
|
||||||
ItemDescription,
|
|
||||||
ItemMedia,
|
|
||||||
ItemTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/item"
|
|
||||||
|
|
||||||
export function AccountAccess() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Account Access</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Update your credentials or re-authenticate.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<FieldGroup>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="email-address">Email Address</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="email-address"
|
|
||||||
type="email"
|
|
||||||
placeholder="artist@studio.inc"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<FieldLabel htmlFor="current-password">
|
|
||||||
Current Password
|
|
||||||
</FieldLabel>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-xs font-medium tracking-wider text-muted-foreground uppercase hover:text-foreground"
|
|
||||||
>
|
|
||||||
Forgot?
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
id="current-password"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••••••••••••••••••"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col gap-4">
|
|
||||||
<Button className="w-full">
|
|
||||||
<HugeiconsIcon icon={SquareLock02Icon} strokeWidth={2} />
|
|
||||||
Update Security
|
|
||||||
</Button>
|
|
||||||
<Item variant="muted" render={<a href="#" />}>
|
|
||||||
<ItemMedia variant="icon">
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={AlertCircleIcon}
|
|
||||||
className="text-destructive"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</ItemMedia>
|
|
||||||
<ItemContent>
|
|
||||||
<ItemTitle>Danger Zone</ItemTitle>
|
|
||||||
<ItemDescription className="line-clamp-1">
|
|
||||||
Archive account and remove catalog
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={ArrowRight01Icon}
|
|
||||||
className="size-4"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</Item>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Badge } from "@/styles/base-rhea/ui/badge"
|
|
||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
|
|
||||||
const areaPath = "M0 52L18 40L36 46L54 70L72 50L100 49V86H0Z"
|
|
||||||
const strokePath = "M0 52L18 40L36 46L54 70L72 50L100 49"
|
|
||||||
|
|
||||||
export function AnalyticsCard() {
|
|
||||||
return (
|
|
||||||
<Card className="mx-auto w-full max-w-sm data-[size=sm]:pb-0" size="sm">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Analytics</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
418.2K Visitors <Badge>+10%</Badge>
|
|
||||||
</CardDescription>
|
|
||||||
<CardAction>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
View Analytics
|
|
||||||
</Button>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 100 86"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
className="aspect-[1/0.35] w-full text-chart-1"
|
|
||||||
role="img"
|
|
||||||
aria-label="Visitor trend"
|
|
||||||
>
|
|
||||||
<path d={areaPath} fill="currentColor" opacity="0.28" />
|
|
||||||
<path
|
|
||||||
d={strokePath}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { Badge } from "@/styles/base-rhea/ui/badge"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Item, ItemContent } from "@/styles/base-rhea/ui/item"
|
|
||||||
import { Separator } from "@/styles/base-rhea/ui/separator"
|
|
||||||
|
|
||||||
const netRoyalties = 1248.75
|
|
||||||
const processingFee = 37.46
|
|
||||||
const totalClaimable = netRoyalties - processingFee
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) =>
|
|
||||||
amount.toLocaleString("en-US", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})
|
|
||||||
|
|
||||||
export function ClaimableBalance() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardDescription>Claimable Balance</CardDescription>
|
|
||||||
<CardTitle className="text-4xl tabular-nums">
|
|
||||||
${formatCurrency(totalClaimable)}
|
|
||||||
</CardTitle>
|
|
||||||
<Badge variant="outline">
|
|
||||||
<span className="size-2 rounded-full bg-yellow-500" />
|
|
||||||
Pending Setup
|
|
||||||
</Badge>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-1 flex-col justify-end">
|
|
||||||
<Item variant="muted" className="flex-col items-stretch">
|
|
||||||
<ItemContent className="gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Net Royalties
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium tabular-nums">
|
|
||||||
${formatCurrency(netRoyalties)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Processing Fee
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium tabular-nums">
|
|
||||||
-${formatCurrency(processingFee)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Total Ready to Claim
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold tabular-nums">
|
|
||||||
${formatCurrency(totalClaimable)} USD
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<CardDescription>
|
|
||||||
Once your bank is connected, balances over $10.00 are automatically
|
|
||||||
eligible for monthly distribution on the 15th of each month.
|
|
||||||
</CardDescription>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { Badge } from "@/styles/base-rhea/ui/badge"
|
|
||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Item, ItemContent, ItemDescription } from "@/styles/base-rhea/ui/item"
|
|
||||||
|
|
||||||
const chartData = [
|
|
||||||
{ month: "Dec", amount: 800 },
|
|
||||||
{ month: "Jan", amount: 1100 },
|
|
||||||
{ month: "Feb", amount: 900 },
|
|
||||||
{ month: "Mar", amount: 1300 },
|
|
||||||
{ month: "Apr", amount: 750 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ContributionHistory() {
|
|
||||||
const maxAmount = Math.max(...chartData.map((item) => item.amount))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Contribution History</CardTitle>
|
|
||||||
<CardDescription>Last 6 months of activity</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div
|
|
||||||
className="flex h-[200px] w-full items-end gap-3"
|
|
||||||
role="img"
|
|
||||||
aria-label="Last 6 months of contribution activity"
|
|
||||||
>
|
|
||||||
{chartData.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={item.month}
|
|
||||||
className="flex h-full flex-1 flex-col justify-end gap-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-index={index}
|
|
||||||
className="data-[index=5]:bg-chart-6 min-h-2 rounded-lg data-[index=0]:bg-chart-1 data-[index=1]:bg-chart-2 data-[index=2]:bg-chart-3 data-[index=3]:bg-chart-4 data-[index=4]:bg-chart-5"
|
|
||||||
style={{ height: `${(item.amount / maxAmount) * 100}%` }}
|
|
||||||
/>
|
|
||||||
<span className="text-center text-xs text-muted-foreground">
|
|
||||||
{item.month}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
|
|
||||||
<Item variant="muted" className="flex-col items-stretch">
|
|
||||||
<ItemContent className="gap-1">
|
|
||||||
<ItemDescription className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
|
||||||
Upcoming
|
|
||||||
</ItemDescription>
|
|
||||||
<span className="cn-font-heading text-base font-semibold">
|
|
||||||
May 2024
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">Scheduled</span>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
<Item
|
|
||||||
variant="muted"
|
|
||||||
className="hidden flex-col items-stretch xl:flex"
|
|
||||||
>
|
|
||||||
<ItemContent className="gap-1">
|
|
||||||
<ItemDescription className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
|
||||||
Savings Plan
|
|
||||||
</ItemDescription>
|
|
||||||
<span className="cn-font-heading text-base font-semibold">
|
|
||||||
Accelerated
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">Recurring</span>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button className="w-full">View Full Report</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { Cancel01Icon } from "@hugeicons/core-free-icons"
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
|
||||||
|
|
||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import {
|
|
||||||
Item,
|
|
||||||
ItemContent,
|
|
||||||
ItemDescription,
|
|
||||||
ItemGroup,
|
|
||||||
ItemTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/item"
|
|
||||||
|
|
||||||
const HOLDINGS = [
|
|
||||||
{
|
|
||||||
name: "Vanguard",
|
|
||||||
shares: "450 Shares",
|
|
||||||
amount: "$1,842.10",
|
|
||||||
data: [
|
|
||||||
{ q: "Q1", value: 380 },
|
|
||||||
{ q: "Q2", value: 420 },
|
|
||||||
{ q: "Q3", value: 390 },
|
|
||||||
{ q: "Q4", value: 652 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "S&P 500 VOO",
|
|
||||||
shares: "112 Shares",
|
|
||||||
amount: "$928.40",
|
|
||||||
data: [
|
|
||||||
{ q: "Q1", value: 180 },
|
|
||||||
{ q: "Q2", value: 210 },
|
|
||||||
{ q: "Q3", value: 320 },
|
|
||||||
{ q: "Q4", value: 218 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Apple AAPL",
|
|
||||||
shares: "85 Shares",
|
|
||||||
amount: "$340.00",
|
|
||||||
data: [
|
|
||||||
{ q: "Q1", value: 60 },
|
|
||||||
{ q: "Q2", value: 70 },
|
|
||||||
{ q: "Q3", value: 120 },
|
|
||||||
{ q: "Q4", value: 90 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Realty Income",
|
|
||||||
shares: "320 Shares",
|
|
||||||
amount: "$1,139.50",
|
|
||||||
data: [
|
|
||||||
{ q: "Q1", value: 240 },
|
|
||||||
{ q: "Q2", value: 260 },
|
|
||||||
{ q: "Q3", value: 280 },
|
|
||||||
{ q: "Q4", value: 360 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function DividendIncome() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Q2 Dividend Income</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Quarterly dividend payouts across your portfolio holdings.
|
|
||||||
</CardDescription>
|
|
||||||
<CardAction>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="bg-muted"
|
|
||||||
aria-label="Dismiss dividend income"
|
|
||||||
>
|
|
||||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
|
|
||||||
</Button>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ItemGroup>
|
|
||||||
{HOLDINGS.map((holding) => (
|
|
||||||
<Item key={holding.name} role="listitem" variant="muted">
|
|
||||||
<ItemContent>
|
|
||||||
<ItemTitle>{holding.name}</ItemTitle>
|
|
||||||
<ItemDescription>{holding.shares}</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
<div
|
|
||||||
className="hidden h-8 w-24 items-end gap-1 md:flex"
|
|
||||||
role="img"
|
|
||||||
aria-label={`${holding.name} quarterly dividends`}
|
|
||||||
>
|
|
||||||
{holding.data.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.q}
|
|
||||||
className="min-h-1 flex-1 rounded-t-sm bg-chart-2"
|
|
||||||
style={{
|
|
||||||
height: `${(item.value / Math.max(...holding.data.map((point) => point.value))) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Item>
|
|
||||||
))}
|
|
||||||
</ItemGroup>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Add01Icon } from "@hugeicons/core-free-icons"
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
|
||||||
|
|
||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import { Card, CardContent } from "@/styles/base-rhea/ui/card"
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyContent,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyMedia,
|
|
||||||
EmptyTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/empty"
|
|
||||||
|
|
||||||
export function EmptyDistributeTrack() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Empty className="p-4">
|
|
||||||
<EmptyMedia variant="icon">
|
|
||||||
<HugeiconsIcon icon={Add01Icon} strokeWidth={2} />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyTitle>Distribute Track</EmptyTitle>
|
|
||||||
<EmptyDescription>
|
|
||||||
Upload your first master to start reaching listeners on Spotify,
|
|
||||||
Apple Music, and more.
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
<EmptyContent>
|
|
||||||
<Button>Create Release</Button>
|
|
||||||
</EmptyContent>
|
|
||||||
</Empty>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { MessageScrollerDemo } from "@/examples/radix/message-scroller-demo"
|
|
||||||
|
|
||||||
import { AccountAccess } from "./account-access"
|
|
||||||
import { AnalyticsCard } from "./analytics-card"
|
|
||||||
import { ClaimableBalance } from "./claimable-balance"
|
|
||||||
import { ContributionHistory } from "./contribution-history"
|
|
||||||
import { DividendIncome } from "./dividend-income"
|
|
||||||
import { EmptyDistributeTrack } from "./empty-distribute-track"
|
|
||||||
import { NewMilestone } from "./new-milestone"
|
|
||||||
import { NotificationSettings } from "./notification-settings"
|
|
||||||
import { Payments } from "./payments"
|
|
||||||
import { PayoutThreshold } from "./payout-threshold"
|
|
||||||
import { PowerUsage } from "./power-usage"
|
|
||||||
import { QrConnect } from "./qr-connect"
|
|
||||||
import { SavingsTargets } from "./savings-targets"
|
|
||||||
import { SidebarNav } from "./sidebar-nav"
|
|
||||||
import { AccountAccess as SkeletonAccountAccess } from "./skeleton/account-access"
|
|
||||||
import { AnalyticsCard as SkeletonAnalyticsCard } from "./skeleton/analytics-card"
|
|
||||||
import { ClaimableBalance as SkeletonClaimableBalance } from "./skeleton/claimable-balance"
|
|
||||||
import { ContributionHistory as SkeletonContributionHistory } from "./skeleton/contribution-history"
|
|
||||||
import { DividendIncome as SkeletonDividendIncome } from "./skeleton/dividend-income"
|
|
||||||
import { EmptyDistributeTrack as SkeletonEmptyDistributeTrack } from "./skeleton/empty-distribute-track"
|
|
||||||
import { NewMilestone as SkeletonNewMilestone } from "./skeleton/new-milestone"
|
|
||||||
import { NotificationSettings as SkeletonNotificationSettings } from "./skeleton/notification-settings"
|
|
||||||
import { Payments as SkeletonPayments } from "./skeleton/payments"
|
|
||||||
import { PayoutThreshold as SkeletonPayoutThreshold } from "./skeleton/payout-threshold"
|
|
||||||
import { PowerUsage as SkeletonPowerUsage } from "./skeleton/power-usage"
|
|
||||||
import { QrConnect as SkeletonQrConnect } from "./skeleton/qr-connect"
|
|
||||||
import { SavingsTargets as SkeletonSavingsTargets } from "./skeleton/savings-targets"
|
|
||||||
import { TransferFunds as SkeletonTransferFunds } from "./skeleton/transfer-funds"
|
|
||||||
import { UIElements as SkeletonUIElements } from "./skeleton/ui-elements"
|
|
||||||
import { TransferFunds } from "./transfer-funds"
|
|
||||||
import { UIElements } from "./ui-elements"
|
|
||||||
|
|
||||||
function CardsSkeletonRails() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute inset-x-0 top-12 z-10 hidden min-[2200px]:block [&_[data-slot=skeleton]:nth-child(even)]:hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 left-[calc(50%-950px-var(--rail-width)-var(--gap))] grid w-(--rail-width) grid-cols-[repeat(2,var(--rail-column))] gap-(--gap) opacity-50 [--rail-column:20rem] [--rail-width:calc(var(--rail-column)*2+var(--gap))]">
|
|
||||||
<div className="flex flex-col gap-(--gap)">
|
|
||||||
<SkeletonContributionHistory />
|
|
||||||
<SkeletonClaimableBalance />
|
|
||||||
<SkeletonDividendIncome />
|
|
||||||
<SkeletonPayoutThreshold />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-(--gap)">
|
|
||||||
<SkeletonUIElements />
|
|
||||||
<SkeletonSavingsTargets />
|
|
||||||
<SkeletonNewMilestone />
|
|
||||||
<SkeletonPayoutThreshold />
|
|
||||||
<SkeletonAccountAccess />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-0 right-[calc(50%-950px-var(--rail-width)-var(--gap))] grid w-(--rail-width) grid-cols-[repeat(2,var(--rail-column))] gap-(--gap) opacity-50 [--rail-column:20rem] [--rail-width:calc(var(--rail-column)*2+var(--gap))]">
|
|
||||||
<div className="flex flex-col gap-(--gap)">
|
|
||||||
<SkeletonNewMilestone />
|
|
||||||
<SkeletonPayoutThreshold />
|
|
||||||
<SkeletonAccountAccess />
|
|
||||||
<SkeletonQrConnect />
|
|
||||||
<SkeletonTransferFunds />
|
|
||||||
<SkeletonPayments />
|
|
||||||
<SkeletonEmptyDistributeTrack />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-(--gap)">
|
|
||||||
<SkeletonQrConnect />
|
|
||||||
<SkeletonTransferFunds />
|
|
||||||
<SkeletonPayments />
|
|
||||||
<SkeletonEmptyDistributeTrack />
|
|
||||||
<SkeletonAnalyticsCard />
|
|
||||||
<SkeletonNotificationSettings />
|
|
||||||
<SkeletonPowerUsage />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardsDemo() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="demo"
|
|
||||||
className="theme-neutral relative flex w-full max-w-none flex-col gap-(--gap) overflow-hidden bg-muted p-12 pb-0! [--gap:--spacing(8)] 3xl:[--gap:--spacing(8)] min-[1900px]:p-12 min-[1900px]:[--gap:--spacing(10)]! lg:p-6 lg:[--gap:--spacing(6)] dark:bg-background"
|
|
||||||
>
|
|
||||||
<CardsSkeletonRails />
|
|
||||||
<div className="relative z-10 mx-auto grid gap-(--gap) **:data-[slot=card]:w-full min-[1400px]:grid-cols-4! min-[1900px]:grid-cols-5! md:max-w-3xl md:grid-cols-2 lg:max-w-none lg:grid-cols-3 xl:max-w-[1600px] 2xl:max-w-[1900px]">
|
|
||||||
<div className="flex flex-col items-start gap-(--gap)">
|
|
||||||
<UIElements />
|
|
||||||
<SidebarNav />
|
|
||||||
<SavingsTargets />
|
|
||||||
</div>
|
|
||||||
<div className="hidden flex-col gap-(--gap) lg:flex">
|
|
||||||
<ContributionHistory />
|
|
||||||
<ClaimableBalance />
|
|
||||||
<DividendIncome />
|
|
||||||
</div>
|
|
||||||
<div className="hidden flex-col gap-(--gap) min-[1400px]:flex">
|
|
||||||
<NewMilestone />
|
|
||||||
<PayoutThreshold />
|
|
||||||
<AccountAccess />
|
|
||||||
</div>
|
|
||||||
<div className="hidden flex-col gap-(--gap) md:flex">
|
|
||||||
<QrConnect />
|
|
||||||
<div className="**:[.text-center.text-xs]:hidden">
|
|
||||||
<MessageScrollerDemo />
|
|
||||||
</div>
|
|
||||||
{/* <TransferFunds /> */}
|
|
||||||
<Payments />
|
|
||||||
</div>
|
|
||||||
<div className="hidden flex-col gap-(--gap) min-[1900px]:flex">
|
|
||||||
<EmptyDistributeTrack />
|
|
||||||
<AnalyticsCard />
|
|
||||||
<NotificationSettings />
|
|
||||||
<PowerUsage />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-x-0 top-0 z-1 h-120 bg-linear-to-b from-background via-muted to-transparent dark:hidden" />
|
|
||||||
<div className="absolute inset-x-0 bottom-0 z-20 h-48 bg-linear-to-t from-background via-muted/80 to-transparent lg:h-80 xl:h-64 dark:via-background/80" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/styles/base-rhea/ui/field"
|
|
||||||
import { Input } from "@/styles/base-rhea/ui/input"
|
|
||||||
|
|
||||||
export function NewMilestone() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Set a new milestone</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Define your financial target and we'll help you pace your
|
|
||||||
savings.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<FieldGroup>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="goal-name">Goal Name</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="goal-name"
|
|
||||||
placeholder="e.g. New Car, Home Downpayment"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="target-amount">Target Amount</FieldLabel>
|
|
||||||
<Input id="target-amount" defaultValue="$15,000" />
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="target-date">Target Date</FieldLabel>
|
|
||||||
<Input id="target-date" defaultValue="Dec 2025" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</FieldGroup>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col gap-2">
|
|
||||||
<Button className="w-full">Create Goal</Button>
|
|
||||||
<Button variant="outline" className="w-full">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Checkbox } from "@/styles/base-rhea/ui/checkbox"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/styles/base-rhea/ui/field"
|
|
||||||
|
|
||||||
const NOTIFICATIONS = [
|
|
||||||
{
|
|
||||||
id: "transactions",
|
|
||||||
label: "Transaction alerts",
|
|
||||||
description: "Deposits, withdrawals, and transfers.",
|
|
||||||
defaultChecked: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "security",
|
|
||||||
label: "Security alerts",
|
|
||||||
description: "Login attempts and account changes.",
|
|
||||||
defaultChecked: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "goals",
|
|
||||||
label: "Goal milestones",
|
|
||||||
description: "Updates at 25%, 50%, 75%, and 100%.",
|
|
||||||
defaultChecked: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "market",
|
|
||||||
label: "Market updates",
|
|
||||||
description: "Daily portfolio summary and price alerts.",
|
|
||||||
defaultChecked: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function NotificationSettings() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Notifications</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Choose which email and push alerts you want to receive.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<FieldGroup>
|
|
||||||
{NOTIFICATIONS.map((n) => (
|
|
||||||
<Field key={n.id} orientation="horizontal">
|
|
||||||
<Checkbox
|
|
||||||
id={`notify-${n.id}`}
|
|
||||||
defaultChecked={n.defaultChecked}
|
|
||||||
/>
|
|
||||||
<FieldContent>
|
|
||||||
<FieldLabel htmlFor={`notify-${n.id}`}>{n.label}</FieldLabel>
|
|
||||||
<FieldDescription>{n.description}</FieldDescription>
|
|
||||||
</FieldContent>
|
|
||||||
</Field>
|
|
||||||
))}
|
|
||||||
</FieldGroup>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button className="w-full">Save Preferences</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import {
|
|
||||||
ArrowRight01Icon,
|
|
||||||
Calendar03Icon,
|
|
||||||
MoreHorizontalCircle01Icon,
|
|
||||||
RefreshIcon,
|
|
||||||
Settings01Icon,
|
|
||||||
} from "@hugeicons/core-free-icons"
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@/styles/base-rhea/ui/breadcrumb"
|
|
||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import { Card, CardContent, CardHeader } from "@/styles/base-rhea/ui/card"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/styles/base-rhea/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
Item,
|
|
||||||
ItemContent,
|
|
||||||
ItemDescription,
|
|
||||||
ItemGroup,
|
|
||||||
ItemMedia,
|
|
||||||
ItemTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/item"
|
|
||||||
|
|
||||||
export function Payments() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-col gap-3">
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList>
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbLink href="#">Home</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator />
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="Account options"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={MoreHorizontalCircle01Icon}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<span className="sr-only">Account options</span>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Statements</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Documents</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator />
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbPage>Payments</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ItemGroup>
|
|
||||||
<div role="listitem" className="w-full">
|
|
||||||
<Item variant="muted" render={<a href="#" />}>
|
|
||||||
<ItemMedia variant="icon">
|
|
||||||
<HugeiconsIcon icon={Settings01Icon} strokeWidth={2} />
|
|
||||||
</ItemMedia>
|
|
||||||
<ItemContent>
|
|
||||||
<ItemTitle>Change transfer limit</ItemTitle>
|
|
||||||
<ItemDescription>
|
|
||||||
Adjust how much you can send from your balance.
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={ArrowRight01Icon}
|
|
||||||
className="size-4 shrink-0 text-muted-foreground"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</Item>
|
|
||||||
</div>
|
|
||||||
<div role="listitem" className="w-full">
|
|
||||||
<Item variant="muted" render={<a href="#" />}>
|
|
||||||
<ItemMedia variant="icon">
|
|
||||||
<HugeiconsIcon icon={Calendar03Icon} strokeWidth={2} />
|
|
||||||
</ItemMedia>
|
|
||||||
<ItemContent>
|
|
||||||
<ItemTitle>Scheduled transfers</ItemTitle>
|
|
||||||
<ItemDescription>
|
|
||||||
Set up a transfer to send at a later date.
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={ArrowRight01Icon}
|
|
||||||
className="size-4 shrink-0 text-muted-foreground"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</Item>
|
|
||||||
</div>
|
|
||||||
<div role="listitem" className="w-full">
|
|
||||||
<Item variant="muted" render={<a href="#" />}>
|
|
||||||
<ItemMedia variant="icon">
|
|
||||||
<HugeiconsIcon icon={RefreshIcon} strokeWidth={2} />
|
|
||||||
</ItemMedia>
|
|
||||||
<ItemContent>
|
|
||||||
<ItemTitle>Recurring card payments</ItemTitle>
|
|
||||||
<ItemDescription>
|
|
||||||
Manage your repeated card transactions.
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={ArrowRight01Icon}
|
|
||||||
className="size-4 shrink-0 text-muted-foreground"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</Item>
|
|
||||||
</div>
|
|
||||||
</ItemGroup>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { Cancel01Icon } from "@hugeicons/core-free-icons"
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
|
||||||
|
|
||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/styles/base-rhea/ui/field"
|
|
||||||
import { Progress } from "@/styles/base-rhea/ui/progress"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/styles/base-rhea/ui/select"
|
|
||||||
import { Textarea } from "@/styles/base-rhea/ui/textarea"
|
|
||||||
|
|
||||||
const CURRENCIES = [
|
|
||||||
{ label: "USD — United States Dollar", value: "usd" },
|
|
||||||
{ label: "EUR — Euro", value: "eur" },
|
|
||||||
{ label: "GBP — British Pound", value: "gbp" },
|
|
||||||
{ label: "JPY — Japanese Yen", value: "jpy" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function PayoutThreshold() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Payout Threshold</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Set the minimum balance required before a payout is triggered.
|
|
||||||
</CardDescription>
|
|
||||||
<CardAction>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="bg-muted"
|
|
||||||
aria-label="Dismiss payout threshold"
|
|
||||||
>
|
|
||||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
|
|
||||||
</Button>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<FieldGroup>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="preferred-currency">
|
|
||||||
Preferred Currency
|
|
||||||
</FieldLabel>
|
|
||||||
<Select items={CURRENCIES} defaultValue="usd">
|
|
||||||
<SelectTrigger id="preferred-currency" className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{CURRENCIES.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<FieldLabel id="min-payout-label">
|
|
||||||
Minimum Payout Amount
|
|
||||||
</FieldLabel>
|
|
||||||
<span className="text-2xl font-semibold tabular-nums">
|
|
||||||
$2500.00
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={25}
|
|
||||||
aria-labelledby="min-payout-label"
|
|
||||||
aria-valuetext="$2,500 of $10,000"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<FieldDescription>$50 (MIN)</FieldDescription>
|
|
||||||
<FieldDescription>$10,000 (MAX)</FieldDescription>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="payout-notes">Notes</FieldLabel>
|
|
||||||
<Textarea
|
|
||||||
id="payout-notes"
|
|
||||||
placeholder="Add any notes for this payout configuration..."
|
|
||||||
className="min-h-[100px]"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button className="w-full">Save Threshold</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Separator } from "@/styles/base-rhea/ui/separator"
|
|
||||||
|
|
||||||
const chartData = [
|
|
||||||
{ hour: "6a", usage: 1.2 },
|
|
||||||
{ hour: "8a", usage: 2.8 },
|
|
||||||
{ hour: "10a", usage: 3.1 },
|
|
||||||
{ hour: "12p", usage: 2.4 },
|
|
||||||
{ hour: "2p", usage: 3.4 },
|
|
||||||
{ hour: "4p", usage: 2.9 },
|
|
||||||
{ hour: "6p", usage: 3.8 },
|
|
||||||
{ hour: "8p", usage: 3.2 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function PowerUsage() {
|
|
||||||
const maxUsage = Math.max(...chartData.map((item) => item.usage))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Power Usage</CardTitle>
|
|
||||||
<CardDescription>Whole Home</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
className="flex h-[140px] w-full items-end gap-2"
|
|
||||||
role="img"
|
|
||||||
aria-label="Power usage by hour"
|
|
||||||
>
|
|
||||||
{chartData.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.hour}
|
|
||||||
className="flex h-full flex-1 flex-col justify-end gap-1.5"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="min-h-2 rounded-t bg-chart-2"
|
|
||||||
style={{ height: `${(item.usage / maxUsage) * 100}%` }}
|
|
||||||
/>
|
|
||||||
<span className="text-center text-xs text-muted-foreground">
|
|
||||||
{item.hour}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Currently Using
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-semibold tabular-nums">3.4 kW</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-sm text-muted-foreground">Solar Gen</span>
|
|
||||||
<span className="text-lg font-semibold tabular-nums">+1.2 kW</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
|
|
||||||
const qrCells = [
|
|
||||||
"111111100101101111111",
|
|
||||||
"100000101001001000001",
|
|
||||||
"101110101111101011101",
|
|
||||||
"101110100100001011101",
|
|
||||||
"101110101010101011101",
|
|
||||||
"100000100111001000001",
|
|
||||||
"111111101010101111111",
|
|
||||||
"000000001101000000000",
|
|
||||||
"101011111001111010110",
|
|
||||||
"010100001110010101001",
|
|
||||||
"111010111011101111010",
|
|
||||||
"001101000101000010101",
|
|
||||||
"110111101111010111011",
|
|
||||||
"000000001001010001010",
|
|
||||||
"111111101101111101001",
|
|
||||||
"100000100010001001111",
|
|
||||||
"101110101011101110100",
|
|
||||||
"101110100110100010011",
|
|
||||||
"101110101000111101110",
|
|
||||||
"100000101101000011001",
|
|
||||||
"111111101011101101111",
|
|
||||||
]
|
|
||||||
|
|
||||||
export function QrConnect() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex justify-center pt-6">
|
|
||||||
<div className="rounded-xl border bg-white p-4">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 21 21"
|
|
||||||
className="size-40 text-black"
|
|
||||||
role="img"
|
|
||||||
aria-label="Connect device QR code"
|
|
||||||
shapeRendering="crispEdges"
|
|
||||||
>
|
|
||||||
<rect width="21" height="21" fill="white" />
|
|
||||||
{qrCells.map((row, y) =>
|
|
||||||
[...row].map((cell, x) =>
|
|
||||||
cell === "1" ? (
|
|
||||||
<rect key={`${x}-${y}`} x={x} y={y} width="1" height="1" />
|
|
||||||
) : null
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<CardTitle>Scan to connect your mobile device</CardTitle>
|
|
||||||
<CardDescription className="text-balance">
|
|
||||||
Open the Ledger mobile app and scan this code to link your device.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import {
|
|
||||||
Item,
|
|
||||||
ItemContent,
|
|
||||||
ItemDescription,
|
|
||||||
ItemFooter,
|
|
||||||
ItemGroup,
|
|
||||||
} from "@/styles/base-rhea/ui/item"
|
|
||||||
import { Progress } from "@/styles/base-rhea/ui/progress"
|
|
||||||
|
|
||||||
export function SavingsTargets() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Savings Targets</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Active milestones for 2024 across your portfolio. Monitor how close
|
|
||||||
you are to each savings goal.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ItemGroup className="gap-3">
|
|
||||||
<Item
|
|
||||||
role="listitem"
|
|
||||||
variant="muted"
|
|
||||||
className="flex-col items-stretch"
|
|
||||||
>
|
|
||||||
<ItemContent className="gap-3">
|
|
||||||
<ItemDescription className="cn-font-heading text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
|
||||||
Retirement
|
|
||||||
</ItemDescription>
|
|
||||||
<span className="text-3xl font-semibold tabular-nums">
|
|
||||||
$420,000
|
|
||||||
</span>
|
|
||||||
<Progress value={65} aria-label="Retirement savings progress" />
|
|
||||||
</ItemContent>
|
|
||||||
<ItemFooter>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
65% achieved
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium tabular-nums">$273,000</span>
|
|
||||||
</ItemFooter>
|
|
||||||
</Item>
|
|
||||||
<Item
|
|
||||||
role="listitem"
|
|
||||||
variant="muted"
|
|
||||||
className="flex-col items-stretch"
|
|
||||||
>
|
|
||||||
<ItemContent className="gap-3">
|
|
||||||
<ItemDescription className="cn-font-heading text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
|
||||||
Real Estate
|
|
||||||
</ItemDescription>
|
|
||||||
<span className="text-3xl font-semibold tabular-nums">
|
|
||||||
$85,000
|
|
||||||
</span>
|
|
||||||
<Progress value={32} aria-label="Real estate savings progress" />
|
|
||||||
</ItemContent>
|
|
||||||
<ItemFooter>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
32% achieved
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium tabular-nums">$27,200</span>
|
|
||||||
</ItemFooter>
|
|
||||||
</Item>
|
|
||||||
</ItemGroup>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<CardDescription className="text-center">
|
|
||||||
You have not met your targets for this year.
|
|
||||||
</CardDescription>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import {
|
|
||||||
ActivityIcon,
|
|
||||||
Analytics01Icon,
|
|
||||||
AnalyticsUpIcon,
|
|
||||||
ArrowDataTransferHorizontalIcon,
|
|
||||||
BankIcon,
|
|
||||||
BookOpen02Icon,
|
|
||||||
Calendar03Icon,
|
|
||||||
ChartBarLineIcon,
|
|
||||||
CreditCardIcon,
|
|
||||||
File02Icon,
|
|
||||||
Globe02Icon,
|
|
||||||
HelpCircleIcon,
|
|
||||||
Message01Icon,
|
|
||||||
Notification03Icon,
|
|
||||||
PaintBoardIcon,
|
|
||||||
PieChartIcon,
|
|
||||||
ShieldIcon,
|
|
||||||
Target02Icon,
|
|
||||||
UserIcon,
|
|
||||||
Wallet01Icon,
|
|
||||||
} from "@hugeicons/core-free-icons"
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Card } from "@/styles/base-rhea/ui/card"
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarProvider,
|
|
||||||
} from "@/styles/base-rhea/ui/sidebar"
|
|
||||||
|
|
||||||
function SidebarSection({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
children: React.ReactNode
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className={cn("w-full overflow-hidden rounded-3xl py-0", className)}>
|
|
||||||
<SidebarProvider className="min-h-0">
|
|
||||||
<Sidebar collapsible="none" className="w-full bg-transparent">
|
|
||||||
<SidebarContent className="gap-0 overflow-hidden">
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu className="gap-1">{children}</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
</Sidebar>
|
|
||||||
</SidebarProvider>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarNav() {
|
|
||||||
return (
|
|
||||||
<div className="grid w-full grid-cols-2 gap-4 xl:gap-6">
|
|
||||||
<SidebarSection
|
|
||||||
label="Overview"
|
|
||||||
className="xl:col-start-1 xl:row-start-2"
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton isActive>
|
|
||||||
<HugeiconsIcon icon={Analytics01Icon} strokeWidth={2} />
|
|
||||||
Analytics
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={ArrowDataTransferHorizontalIcon}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
Transactions
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={AnalyticsUpIcon} strokeWidth={2} />
|
|
||||||
Investments
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={BankIcon} strokeWidth={2} />
|
|
||||||
Accounts
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={PieChartIcon} strokeWidth={2} />
|
|
||||||
Spending
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarSection>
|
|
||||||
|
|
||||||
<SidebarSection
|
|
||||||
label="Planning"
|
|
||||||
className="xl:col-start-1 xl:row-start-1"
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={File02Icon} strokeWidth={2} />
|
|
||||||
Documents
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={Wallet01Icon} strokeWidth={2} />
|
|
||||||
Budget
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={ChartBarLineIcon} strokeWidth={2} />
|
|
||||||
Reports
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={Target02Icon} strokeWidth={2} />
|
|
||||||
Goals
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={Calendar03Icon} strokeWidth={2} />
|
|
||||||
Calendar
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarSection>
|
|
||||||
|
|
||||||
<SidebarSection
|
|
||||||
label="Support"
|
|
||||||
className="flex xl:col-start-2 xl:row-start-1"
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={2} />
|
|
||||||
Help Center
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={BookOpen02Icon} strokeWidth={2} />
|
|
||||||
Docs
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={Message01Icon} strokeWidth={2} />
|
|
||||||
Contact Us
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={ActivityIcon} strokeWidth={2} />
|
|
||||||
Status
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={Globe02Icon} strokeWidth={2} />
|
|
||||||
Community
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarSection>
|
|
||||||
|
|
||||||
<SidebarSection
|
|
||||||
label="Account"
|
|
||||||
className="flex xl:col-start-2 xl:row-start-2"
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={UserIcon} strokeWidth={2} />
|
|
||||||
Profile
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton isActive>
|
|
||||||
<HugeiconsIcon icon={CreditCardIcon} strokeWidth={2} />
|
|
||||||
Billing
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={Notification03Icon} strokeWidth={2} />
|
|
||||||
Notifications
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={ShieldIcon} strokeWidth={2} />
|
|
||||||
Security
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton>
|
|
||||||
<HugeiconsIcon icon={PaintBoardIcon} strokeWidth={2} />
|
|
||||||
Appearance
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarSection>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
export function AccountAccess() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<Skeleton className="h-5 w-36 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-64 rounded-md" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-3 w-24 rounded-md" />
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-3 w-32 rounded-md" />
|
|
||||||
<Skeleton className="h-3 w-12 rounded-md" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col gap-4">
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
<Skeleton className="h-14 w-full rounded-xl" />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Card, CardAction, CardHeader } from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
export function AnalyticsCard() {
|
|
||||||
return (
|
|
||||||
<Card className="mx-auto w-full max-w-sm data-[size=sm]:pb-0" size="sm">
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<Skeleton className="h-5 w-24 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-40 rounded-md" />
|
|
||||||
<CardAction>
|
|
||||||
<Skeleton className="h-7 w-28 rounded-lg" />
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<Skeleton className="mx-6 mb-6 aspect-[1/0.35] w-auto rounded-lg" />
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
export function ClaimableBalance() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-3">
|
|
||||||
<Skeleton className="h-4 w-36 rounded-md" />
|
|
||||||
<Skeleton className="h-12 w-56 rounded-lg" />
|
|
||||||
<Skeleton className="h-6 w-32 rounded-full" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-1 flex-col justify-end">
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl bg-muted p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-4 w-28 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-4 w-20 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-4 w-32 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-4 w-16 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-px w-full rounded-none bg-muted-foreground/15" />
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-4 w-36 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-4 w-24 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col gap-2">
|
|
||||||
<Skeleton className="h-3 w-full rounded-md" />
|
|
||||||
<Skeleton className="h-3 w-11/12 rounded-md" />
|
|
||||||
<Skeleton className="h-3 w-3/4 rounded-md" />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
const bars = [60, 80, 65, 95, 50, 100]
|
|
||||||
|
|
||||||
export function ContributionHistory() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<Skeleton className="h-5 w-44 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-52 rounded-md" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex h-[200px] w-full items-end gap-3">
|
|
||||||
{bars.map((height, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex h-full flex-1 flex-col justify-end gap-2"
|
|
||||||
>
|
|
||||||
<Skeleton
|
|
||||||
className="w-full rounded-t-md rounded-b-none"
|
|
||||||
style={{ height: `${height}%` }}
|
|
||||||
/>
|
|
||||||
<Skeleton className="mx-auto h-3 w-6 rounded-md" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-2 rounded-xl bg-muted p-4">
|
|
||||||
<Skeleton className="h-3 w-20 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-5 w-28 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-3 w-24 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
<div className="hidden flex-col gap-2 rounded-xl bg-muted p-4 xl:flex">
|
|
||||||
<Skeleton className="h-3 w-24 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-5 w-32 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-3 w-28 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
const rows = [0, 1, 2, 3]
|
|
||||||
const miniBars = [40, 60, 80, 50]
|
|
||||||
|
|
||||||
export function DividendIncome() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<Skeleton className="h-5 w-48 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-64 rounded-md" />
|
|
||||||
<CardAction>
|
|
||||||
<Skeleton className="size-8 rounded-md" />
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{rows.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row}
|
|
||||||
className="flex items-center gap-3 rounded-xl bg-muted p-3"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
|
||||||
<Skeleton className="h-4 w-28 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-3 w-20 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
<div className="hidden h-8 w-24 items-end gap-1 md:flex">
|
|
||||||
{miniBars.map((h, i) => (
|
|
||||||
<Skeleton
|
|
||||||
key={i}
|
|
||||||
className="flex-1 rounded-t-sm rounded-b-none bg-muted-foreground/15"
|
|
||||||
style={{ height: `${h}%` }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Skeleton className="hidden h-4 w-16 rounded-md bg-muted-foreground/15 md:block" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Card, CardContent } from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
export function EmptyDistributeTrack() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col items-center gap-4 p-4">
|
|
||||||
<Skeleton className="size-12 rounded-xl" />
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<Skeleton className="h-5 w-40 rounded-md" />
|
|
||||||
<Skeleton className="h-3 w-64 rounded-md" />
|
|
||||||
<Skeleton className="h-3 w-48 rounded-md" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-9 w-32 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { AccountAccess } from "./account-access"
|
|
||||||
import { AnalyticsCard } from "./analytics-card"
|
|
||||||
import { ClaimableBalance } from "./claimable-balance"
|
|
||||||
import { ContributionHistory } from "./contribution-history"
|
|
||||||
import { DividendIncome } from "./dividend-income"
|
|
||||||
import { EmptyDistributeTrack } from "./empty-distribute-track"
|
|
||||||
import { NewMilestone } from "./new-milestone"
|
|
||||||
import { NotificationSettings } from "./notification-settings"
|
|
||||||
import { Payments } from "./payments"
|
|
||||||
import { PayoutThreshold } from "./payout-threshold"
|
|
||||||
import { PowerUsage } from "./power-usage"
|
|
||||||
import { QrConnect } from "./qr-connect"
|
|
||||||
import { SavingsTargets } from "./savings-targets"
|
|
||||||
import { SidebarNav } from "./sidebar-nav"
|
|
||||||
import { TransferFunds } from "./transfer-funds"
|
|
||||||
import { UIElements } from "./ui-elements"
|
|
||||||
|
|
||||||
export function CardsSkeletonDemo() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="demo"
|
|
||||||
className="theme-neutral relative flex w-full max-w-none flex-col gap-(--gap) bg-muted p-12 pb-0! [--gap:--spacing(8)] 3xl:[--gap:--spacing(8)] min-[1900px]:[--gap:--spacing(10)]! lg:p-8 lg:[--gap:--spacing(6)] xl:p-12 dark:bg-muted/30"
|
|
||||||
>
|
|
||||||
<div className="relative z-10 mx-auto grid gap-(--gap) **:data-[slot=card]:w-full min-[1900px]:grid-cols-5! md:max-w-3xl md:grid-cols-2 lg:max-w-none lg:grid-cols-3 xl:max-w-[1600px] xl:grid-cols-4 2xl:max-w-[1900px]">
|
|
||||||
<div className="flex flex-col items-start gap-(--gap)">
|
|
||||||
<UIElements />
|
|
||||||
<SidebarNav />
|
|
||||||
<SavingsTargets />
|
|
||||||
</div>
|
|
||||||
<div className="hidden flex-col gap-(--gap) lg:flex">
|
|
||||||
<ContributionHistory />
|
|
||||||
<ClaimableBalance />
|
|
||||||
<DividendIncome />
|
|
||||||
</div>
|
|
||||||
<div className="hidden flex-col gap-(--gap) 3xl:flex!">
|
|
||||||
<NewMilestone />
|
|
||||||
<PayoutThreshold />
|
|
||||||
<AccountAccess />
|
|
||||||
</div>
|
|
||||||
<div className="hidden flex-col gap-(--gap) md:flex">
|
|
||||||
<QrConnect />
|
|
||||||
<TransferFunds />
|
|
||||||
<Payments />
|
|
||||||
</div>
|
|
||||||
<div className="hidden flex-col gap-(--gap) xl:flex">
|
|
||||||
<EmptyDistributeTrack />
|
|
||||||
<AnalyticsCard />
|
|
||||||
<NotificationSettings />
|
|
||||||
<PowerUsage />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-x-0 top-0 z-1 h-80 bg-linear-to-b from-background via-muted to-transparent dark:via-muted/30" />
|
|
||||||
<div className="absolute inset-x-0 bottom-0 z-20 h-80 bg-linear-to-t from-background via-muted to-transparent dark:via-muted/30" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
export function NewMilestone() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<Skeleton className="h-5 w-44 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-72 rounded-md" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-3 w-20 rounded-md" />
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-3 w-24 rounded-md" />
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-3 w-20 rounded-md" />
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col gap-2">
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
const rows = [0, 1, 2, 3]
|
|
||||||
|
|
||||||
export function NotificationSettings() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<Skeleton className="h-5 w-32 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-64 rounded-md" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
{rows.map((row) => (
|
|
||||||
<div key={row} className="flex items-start gap-3">
|
|
||||||
<Skeleton className="size-4 rounded-sm" />
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
|
||||||
<Skeleton className="h-4 w-40 rounded-md" />
|
|
||||||
<Skeleton className="h-3 w-56 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Card, CardContent, CardHeader } from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
const rows = [0, 1, 2]
|
|
||||||
|
|
||||||
export function Payments() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-4 w-12 rounded-md" />
|
|
||||||
<Skeleton className="size-1.5 rounded-full" />
|
|
||||||
<Skeleton className="size-7 rounded-md" />
|
|
||||||
<Skeleton className="size-1.5 rounded-full" />
|
|
||||||
<Skeleton className="h-4 w-20 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{rows.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row}
|
|
||||||
className="flex items-center gap-3 rounded-xl bg-muted p-3"
|
|
||||||
>
|
|
||||||
<Skeleton className="size-9 rounded-lg bg-muted-foreground/15" />
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
|
||||||
<Skeleton className="h-4 w-40 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-3 w-56 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="size-4 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
export function PayoutThreshold() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<Skeleton className="h-5 w-44 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-72 rounded-md" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-3 w-32 rounded-md" />
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<Skeleton className="h-3 w-40 rounded-md" />
|
|
||||||
<Skeleton className="h-7 w-24 rounded-md" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-2 w-full rounded-full" />
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-3 w-16 rounded-md" />
|
|
||||||
<Skeleton className="h-3 w-20 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-3 w-16 rounded-md" />
|
|
||||||
<Skeleton className="h-[100px] w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
const bars = [30, 70, 80, 60, 90, 75, 100, 85]
|
|
||||||
|
|
||||||
export function PowerUsage() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<Skeleton className="h-5 w-32 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-24 rounded-md" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div className="flex h-[140px] w-full items-end gap-2">
|
|
||||||
{bars.map((height, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex h-full flex-1 flex-col justify-end gap-1.5"
|
|
||||||
>
|
|
||||||
<Skeleton
|
|
||||||
className="w-full rounded-t rounded-b-none"
|
|
||||||
style={{ height: `${height}%` }}
|
|
||||||
/>
|
|
||||||
<Skeleton className="mx-auto h-3 w-5 rounded-md" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-px w-full rounded-none" />
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Skeleton className="h-3 w-28 rounded-md" />
|
|
||||||
<Skeleton className="h-5 w-20 rounded-md" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Skeleton className="h-3 w-20 rounded-md" />
|
|
||||||
<Skeleton className="h-5 w-24 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col items-start gap-2">
|
|
||||||
<Skeleton className="h-3 w-24 rounded-md" />
|
|
||||||
<div className="flex w-full items-center gap-2">
|
|
||||||
<Skeleton className="h-2 flex-1 rounded-full" />
|
|
||||||
<Skeleton className="h-3 w-10 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Card, CardContent, CardHeader } from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
export function QrConnect() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex justify-center pt-6">
|
|
||||||
<Skeleton className="size-44 rounded-xl" />
|
|
||||||
</CardContent>
|
|
||||||
<CardHeader className="items-center gap-2 text-center">
|
|
||||||
<Skeleton className="h-5 w-56 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-64 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-48 rounded-md" />
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
const rows = [0, 1]
|
|
||||||
|
|
||||||
export function SavingsTargets() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<Skeleton className="h-5 w-36 rounded-md" />
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Skeleton className="h-4 w-full max-w-64 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-48 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{rows.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row}
|
|
||||||
className="flex flex-col gap-3 rounded-xl bg-muted p-4"
|
|
||||||
>
|
|
||||||
<Skeleton className="h-3 w-24 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-8 w-36 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-2 w-full rounded-full bg-muted-foreground/15" />
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-3 w-24 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-3 w-20 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="justify-center">
|
|
||||||
<Skeleton className="h-3 w-56 rounded-md" />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Card } from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
const groupA = [0, 1, 2, 3, 4]
|
|
||||||
const groupB = [0, 1, 2, 3, 4]
|
|
||||||
|
|
||||||
function NavSkeleton({ groups }: { groups: number[][] }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1 p-2">
|
|
||||||
{groups.map((items, gi) => (
|
|
||||||
<div key={gi} className="flex flex-col gap-1 px-2 py-1.5">
|
|
||||||
<Skeleton className="mb-1 h-3 w-20 rounded-md" />
|
|
||||||
{items.map((item) => (
|
|
||||||
<div key={item} className="flex items-center gap-2 px-2 py-2">
|
|
||||||
<Skeleton className="size-4 rounded-md" />
|
|
||||||
<Skeleton className="h-3 w-24 rounded-md" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{gi < groups.length - 1 && (
|
|
||||||
<Skeleton className="my-1 h-px w-full rounded-none" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarNav() {
|
|
||||||
return (
|
|
||||||
<div className="grid w-full items-start gap-4 xl:grid-cols-2 xl:gap-6">
|
|
||||||
<Card className="w-full overflow-hidden rounded-3xl py-0">
|
|
||||||
<NavSkeleton groups={[groupA, groupB]} />
|
|
||||||
</Card>
|
|
||||||
<Card className="hidden w-full overflow-hidden rounded-3xl py-0 xl:flex">
|
|
||||||
<NavSkeleton groups={[groupA, groupB]} />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
export function TransferFunds() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<Skeleton className="h-5 w-36 rounded-md" />
|
|
||||||
<Skeleton className="h-4 w-64 rounded-md" />
|
|
||||||
<CardAction>
|
|
||||||
<Skeleton className="size-8 rounded-md" />
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-3 w-32 rounded-md" />
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-3 w-24 rounded-md" />
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Skeleton className="h-3 w-20 rounded-md" />
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl bg-muted p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-4 w-28 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-4 w-24 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-px w-full rounded-none bg-muted-foreground/15" />
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-4 w-28 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-4 w-12 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-px w-full rounded-none bg-muted-foreground/15" />
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-4 w-24 rounded-md bg-muted-foreground/15" />
|
|
||||||
<Skeleton className="h-4 w-20 rounded-md bg-muted-foreground/15" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Card, CardContent } from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Skeleton } from "@/styles/base-rhea/ui/skeleton"
|
|
||||||
|
|
||||||
export function UIElements() {
|
|
||||||
return (
|
|
||||||
<Card className="w-full">
|
|
||||||
<CardContent className="flex flex-col gap-6">
|
|
||||||
<Skeleton className="h-8 w-full rounded-2xl" />
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Skeleton className="h-9 w-20 rounded-lg" />
|
|
||||||
<Skeleton className="h-9 w-24 rounded-lg" />
|
|
||||||
<Skeleton className="h-9 w-20 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Skeleton className="h-9 w-full rounded-lg" />
|
|
||||||
<Skeleton className="h-20 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Skeleton className="h-5 w-12 rounded-full" />
|
|
||||||
<Skeleton className="h-5 w-16 rounded-full" />
|
|
||||||
<Skeleton className="hidden h-5 w-14 rounded-full 4xl:block" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto flex gap-3">
|
|
||||||
<Skeleton className="size-4 rounded-full" />
|
|
||||||
<Skeleton className="size-4 rounded-full" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Skeleton className="size-4 rounded-sm" />
|
|
||||||
<Skeleton className="hidden size-4 rounded-sm 4xl:block" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="ml-auto h-5 w-9 rounded-full 4xl:hidden" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-9 w-24 rounded-lg" />
|
|
||||||
<div className="flex">
|
|
||||||
<Skeleton className="h-9 w-28 rounded-l-lg rounded-r-none" />
|
|
||||||
<Skeleton className="ml-px h-9 w-9 rounded-l-none rounded-r-lg" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="ml-auto hidden h-5 w-9 rounded-full 4xl:block" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { Cancel01Icon } from "@hugeicons/core-free-icons"
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
|
||||||
|
|
||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/styles/base-rhea/ui/field"
|
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupInput,
|
|
||||||
InputGroupText,
|
|
||||||
} from "@/styles/base-rhea/ui/input-group"
|
|
||||||
import { Item, ItemContent } from "@/styles/base-rhea/ui/item"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/styles/base-rhea/ui/select"
|
|
||||||
import { Separator } from "@/styles/base-rhea/ui/separator"
|
|
||||||
|
|
||||||
const FROM_ACCOUNTS = [
|
|
||||||
{ label: "Main Checking (··8402) — $12,450.00", value: "checking" },
|
|
||||||
{ label: "Business (··7731) — $8,920.00", value: "business" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TO_ACCOUNTS = [
|
|
||||||
{ label: "High Yield Savings (··1192) — $42,100.00", value: "savings" },
|
|
||||||
{ label: "Investment (··3349) — $18,200.00", value: "investment" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function TransferFunds() {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Transfer Funds</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Move money between your connected accounts.
|
|
||||||
</CardDescription>
|
|
||||||
<CardAction>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="bg-muted"
|
|
||||||
aria-label="Dismiss transfer funds"
|
|
||||||
>
|
|
||||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
|
|
||||||
</Button>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<FieldGroup>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="transfer-amount">
|
|
||||||
Amount to Transfer
|
|
||||||
</FieldLabel>
|
|
||||||
<InputGroup>
|
|
||||||
<InputGroupAddon>
|
|
||||||
<InputGroupText>$</InputGroupText>
|
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupInput id="transfer-amount" defaultValue="1,200.00" />
|
|
||||||
</InputGroup>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="from-account">From Account</FieldLabel>
|
|
||||||
<Select items={FROM_ACCOUNTS} defaultValue="checking">
|
|
||||||
<SelectTrigger id="from-account" className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{FROM_ACCOUNTS.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="to-account">To Account</FieldLabel>
|
|
||||||
<Select items={TO_ACCOUNTS} defaultValue="savings">
|
|
||||||
<SelectTrigger id="to-account" className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{TO_ACCOUNTS.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Item variant="muted" className="flex-col items-stretch">
|
|
||||||
<ItemContent className="gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Estimated arrival
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium">Today, Apr 14</span>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Transaction fee
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium tabular-nums">$0.00</span>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium">Total amount</span>
|
|
||||||
<span className="text-sm font-semibold tabular-nums">
|
|
||||||
$1,200.00
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
</FieldGroup>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button className="w-full">Confirm Transfer</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowRight02Icon,
|
|
||||||
ArrowUp01Icon,
|
|
||||||
Search01Icon,
|
|
||||||
} from "@hugeicons/core-free-icons"
|
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/styles/base-rhea/ui/alert-dialog"
|
|
||||||
import { Badge } from "@/styles/base-rhea/ui/badge"
|
|
||||||
import { Button } from "@/styles/base-rhea/ui/button"
|
|
||||||
import { ButtonGroup } from "@/styles/base-rhea/ui/button-group"
|
|
||||||
import { Card, CardContent } from "@/styles/base-rhea/ui/card"
|
|
||||||
import { Checkbox } from "@/styles/base-rhea/ui/checkbox"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/styles/base-rhea/ui/dropdown-menu"
|
|
||||||
import { Field, FieldGroup } from "@/styles/base-rhea/ui/field"
|
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupInput,
|
|
||||||
InputGroupText,
|
|
||||||
} from "@/styles/base-rhea/ui/input-group"
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/styles/base-rhea/ui/radio-group"
|
|
||||||
import { Switch } from "@/styles/base-rhea/ui/switch"
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "@/styles/base-rhea/ui/tabs"
|
|
||||||
import { Textarea } from "@/styles/base-rhea/ui/textarea"
|
|
||||||
|
|
||||||
export function UIElements() {
|
|
||||||
return (
|
|
||||||
<Card className="w-full">
|
|
||||||
<CardContent className="flex flex-col gap-6">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button>
|
|
||||||
Button{" "}
|
|
||||||
<HugeiconsIcon
|
|
||||||
icon={ArrowRight02Icon}
|
|
||||||
strokeWidth={2}
|
|
||||||
data-icon="inline-end"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary">Secondary</Button>
|
|
||||||
<Button variant="outline">Outline</Button>
|
|
||||||
</div>
|
|
||||||
<FieldGroup>
|
|
||||||
<Field>
|
|
||||||
<InputGroup>
|
|
||||||
<InputGroupInput placeholder="Name" />
|
|
||||||
<InputGroupAddon align="inline-end">
|
|
||||||
<InputGroupText>
|
|
||||||
<HugeiconsIcon icon={Search01Icon} strokeWidth={2} />
|
|
||||||
</InputGroupText>
|
|
||||||
</InputGroupAddon>
|
|
||||||
</InputGroup>
|
|
||||||
</Field>
|
|
||||||
<Field className="flex-1">
|
|
||||||
<Textarea placeholder="Message" className="resize-none" />
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge>Badge</Badge>
|
|
||||||
<Badge variant="secondary">Secondary</Badge>
|
|
||||||
<Badge variant="outline" className="hidden 4xl:flex">
|
|
||||||
Outline
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<RadioGroup
|
|
||||||
defaultValue="apple"
|
|
||||||
className="ml-auto flex w-fit gap-3"
|
|
||||||
aria-label="Fruit preference"
|
|
||||||
>
|
|
||||||
<RadioGroupItem value="apple" aria-label="Apple" />
|
|
||||||
<RadioGroupItem value="banana" aria-label="Banana" />
|
|
||||||
</RadioGroup>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Checkbox defaultChecked aria-label="Enable email alerts" />
|
|
||||||
<Checkbox
|
|
||||||
className="hidden 4xl:flex"
|
|
||||||
aria-label="Enable push alerts"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
defaultChecked
|
|
||||||
className="flex 4xl:hidden"
|
|
||||||
aria-label="Enable compact notifications"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger render={<Button variant="outline" />}>
|
|
||||||
<span className="hidden md:flex style-sera:md:hidden">
|
|
||||||
Alert Dialog
|
|
||||||
</span>
|
|
||||||
<span className="flex md:hidden style-sera:md:flex">Dialog</span>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent size="sm" className="theme-neutral">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Allow accessory to connect?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Do you want to allow the USB accessory to connect to this
|
|
||||||
device and your data?
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Don't allow</AlertDialogCancel>
|
|
||||||
<AlertDialogAction>Allow</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
<ButtonGroup className="ml-auto">
|
|
||||||
<Button variant="outline">
|
|
||||||
<span className="style-sera:hidden">Button Group</span>
|
|
||||||
<span className="hidden style-sera:block">Group</span>
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Open quick actions"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" side="top" className="w-40">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>Quick Actions</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem>Mute Conversation</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Mark as Read</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Block User</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem variant="destructive">
|
|
||||||
Delete Conversation
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</ButtonGroup>
|
|
||||||
<Switch
|
|
||||||
defaultChecked
|
|
||||||
className="hidden 4xl:flex"
|
|
||||||
aria-label="Enable advanced setting"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
134
apps/v4/app/(app)/(root)/components/appearance-settings.tsx
Normal file
134
apps/v4/app/(app)/(root)/components/appearance-settings.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button } from "@/examples/radix/ui/button"
|
||||||
|
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldTitle,
|
||||||
|
} 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)
|
||||||
|
|
||||||
|
const handleGpuAdjustment = React.useCallback((adjustment: number) => {
|
||||||
|
setGpuCount((prevCount) =>
|
||||||
|
Math.max(1, Math.min(99, prevCount + adjustment))
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleGpuInputChange = React.useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = parseInt(e.target.value, 10)
|
||||||
|
if (!isNaN(value) && value >= 1 && value <= 99) {
|
||||||
|
setGpuCount(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldSet>
|
||||||
|
<FieldGroup>
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend>Compute Environment</FieldLegend>
|
||||||
|
<FieldDescription>
|
||||||
|
Select the compute environment for your cluster.
|
||||||
|
</FieldDescription>
|
||||||
|
<RadioGroup defaultValue="kubernetes">
|
||||||
|
<FieldLabel htmlFor="kubernetes-r2h">
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldContent>
|
||||||
|
<FieldTitle>Kubernetes</FieldTitle>
|
||||||
|
<FieldDescription>
|
||||||
|
Run GPU workloads on a K8s configured cluster. This is the
|
||||||
|
default.
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
<RadioGroupItem
|
||||||
|
value="kubernetes"
|
||||||
|
id="kubernetes-r2h"
|
||||||
|
aria-label="Kubernetes"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldLabel htmlFor="vm-z4k">
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldContent>
|
||||||
|
<FieldTitle>Virtual Machine</FieldTitle>
|
||||||
|
<FieldDescription>
|
||||||
|
Access a VM configured cluster to run workloads. (Coming
|
||||||
|
soon)
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
<RadioGroupItem
|
||||||
|
value="vm"
|
||||||
|
id="vm-z4k"
|
||||||
|
aria-label="Virtual Machine"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldLabel>
|
||||||
|
</RadioGroup>
|
||||||
|
</FieldSet>
|
||||||
|
<FieldSeparator />
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldContent>
|
||||||
|
<FieldLabel htmlFor="number-of-gpus-f6l">Number of GPUs</FieldLabel>
|
||||||
|
<FieldDescription>You can add more later.</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Input
|
||||||
|
id="number-of-gpus-f6l"
|
||||||
|
value={gpuCount}
|
||||||
|
onChange={handleGpuInputChange}
|
||||||
|
size={3}
|
||||||
|
className="h-7 w-14! font-mono"
|
||||||
|
maxLength={3}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
aria-label="Decrement"
|
||||||
|
onClick={() => handleGpuAdjustment(-1)}
|
||||||
|
disabled={gpuCount <= 1}
|
||||||
|
>
|
||||||
|
<IconMinus />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
aria-label="Increment"
|
||||||
|
onClick={() => handleGpuAdjustment(1)}
|
||||||
|
disabled={gpuCount >= 99}
|
||||||
|
>
|
||||||
|
<IconPlus />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Field>
|
||||||
|
<FieldSeparator />
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldContent>
|
||||||
|
<FieldLabel htmlFor="tinting">Wallpaper Tinting</FieldLabel>
|
||||||
|
<FieldDescription>
|
||||||
|
Allow the wallpaper to be tinted.
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldContent>
|
||||||
|
<Switch id="tinting" defaultChecked />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
apps/v4/app/(app)/(root)/components/button-group-demo.tsx
Normal file
119
apps/v4/app/(app)/(root)/components/button-group-demo.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button } from "@/examples/radix/ui/button"
|
||||||
|
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} 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")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroup>
|
||||||
|
<ButtonGroup className="hidden sm:flex">
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Go Back">
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Report
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Snooze
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="More Options">
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<MailCheckIcon />
|
||||||
|
Mark as Read
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<ArchiveIcon />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<ClockIcon />
|
||||||
|
Snooze
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<CalendarPlusIcon />
|
||||||
|
Add to Calendar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<ListFilterIcon />
|
||||||
|
Add to List
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<TagIcon />
|
||||||
|
Label As...
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={label}
|
||||||
|
onValueChange={setLabel}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="personal">
|
||||||
|
Personal
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="work">
|
||||||
|
Work
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="other">
|
||||||
|
Other
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem variant="destructive">
|
||||||
|
<Trash2Icon />
|
||||||
|
Trash
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</ButtonGroup>
|
||||||
|
</ButtonGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button } from "@/examples/radix/ui/button"
|
||||||
|
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@/examples/radix/ui/input-group"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/examples/radix/ui/tooltip"
|
||||||
|
import { AudioLinesIcon, PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function ButtonGroupInputGroup() {
|
||||||
|
const [voiceEnabled, setVoiceEnabled] = React.useState(false)
|
||||||
|
return (
|
||||||
|
<ButtonGroup className="[--radius:9999rem]">
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button variant="outline" size="icon" aria-label="Add">
|
||||||
|
<PlusIcon />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup className="flex-1">
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
placeholder={
|
||||||
|
voiceEnabled ? "Record and send audio..." : "Send a message..."
|
||||||
|
}
|
||||||
|
disabled={voiceEnabled}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<InputGroupButton
|
||||||
|
onClick={() => setVoiceEnabled(!voiceEnabled)}
|
||||||
|
data-active={voiceEnabled}
|
||||||
|
className="data-[active=true]:bg-primary data-[active=true]:text-primary-foreground"
|
||||||
|
aria-pressed={voiceEnabled}
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="Voice Mode"
|
||||||
|
>
|
||||||
|
<AudioLinesIcon />
|
||||||
|
</InputGroupButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Voice Mode</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</ButtonGroup>
|
||||||
|
</ButtonGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
apps/v4/app/(app)/(root)/components/button-group-nested.tsx
Normal file
31
apps/v4/app/(app)/(root)/components/button-group-nested.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/examples/radix/ui/button"
|
||||||
|
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
||||||
|
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function ButtonGroupNested() {
|
||||||
|
return (
|
||||||
|
<ButtonGroup>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
1
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
2
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
3
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Previous">
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Next">
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</ButtonGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user