mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-07 22:18:39 +00:00
Compare commits
138 Commits
fix/cn-cla
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e1f171aa | ||
|
|
dd6ce77cf1 | ||
|
|
628e427231 | ||
|
|
d8ace420ba | ||
|
|
4163c1dbea | ||
|
|
468674b1f0 | ||
|
|
b13050763e | ||
|
|
c4b22943fe | ||
|
|
34f0dabe48 | ||
|
|
5b5db1f8ea | ||
|
|
d0fae52822 | ||
|
|
2ef6388ca1 | ||
|
|
f3e7de1175 | ||
|
|
a409271270 | ||
|
|
70afde8358 | ||
|
|
574e85e22d | ||
|
|
3310c45dfb | ||
|
|
34223a842d | ||
|
|
dbf9c5ebc4 | ||
|
|
897e9add14 | ||
|
|
8d6553a7f5 | ||
|
|
683073f102 | ||
|
|
02e398ab73 | ||
|
|
af79276f7e | ||
|
|
5a3ad36a5e | ||
|
|
a63e8359ec | ||
|
|
a72491cb9b | ||
|
|
67aec7dcc5 | ||
|
|
40c7064532 | ||
|
|
5b3369f6ee | ||
|
|
b31e6d63b0 | ||
|
|
cf5b227565 | ||
|
|
8055a12f46 | ||
|
|
18fcf0f766 | ||
|
|
c520191cd4 | ||
|
|
35983528c2 | ||
|
|
8692cd4cc1 | ||
|
|
d3727d8c45 | ||
|
|
e3f98d49e4 | ||
|
|
549852bffc | ||
|
|
4139cff3a0 | ||
|
|
95471a0fb9 | ||
|
|
b59f68ecc5 | ||
|
|
6956a099b3 | ||
|
|
2417242b12 | ||
|
|
70a7a0c2a8 | ||
|
|
5602b81d83 | ||
|
|
3ffd3e1c7c | ||
|
|
e03df56fcf | ||
|
|
38fb1b6f41 | ||
|
|
13eb6a81d1 | ||
|
|
5cc8a2af42 | ||
|
|
365d53b590 | ||
|
|
c2ddedf5d2 | ||
|
|
c879483c96 | ||
|
|
4885a9a7ad | ||
|
|
2f5929269a | ||
|
|
2fdf1bb0d4 | ||
|
|
951750bdbe | ||
|
|
6c5d5d6374 | ||
|
|
6ec117715f | ||
|
|
e583c773fe | ||
|
|
0f154171a7 | ||
|
|
9197676b3d | ||
|
|
82dce7e945 | ||
|
|
35bc9934bf | ||
|
|
1fd75c9d7e | ||
|
|
2ea31d8070 | ||
|
|
ea9d371a2d | ||
|
|
d15f17d717 | ||
|
|
46ca8c5d4b | ||
|
|
a5eb279650 | ||
|
|
1994caba0b | ||
|
|
1450bea8d6 | ||
|
|
ced2a5beb5 | ||
|
|
10f1717a3e | ||
|
|
deba036d50 | ||
|
|
5bd81beebf | ||
|
|
3f2ff18157 | ||
|
|
05eb2b968b | ||
|
|
a721cc08e5 | ||
|
|
8da4592308 | ||
|
|
f47d48f316 | ||
|
|
e6d9d6023b | ||
|
|
7dfd933102 | ||
|
|
9c6a5ee1b1 | ||
|
|
c87897b2a5 | ||
|
|
c61197f627 | ||
|
|
a1fb619cef | ||
|
|
d84c4a8ca5 | ||
|
|
cd54e0927f | ||
|
|
adac7cae1f | ||
|
|
7c63c46736 | ||
|
|
916c012132 | ||
|
|
460ad60d84 | ||
|
|
8e2d2d1439 | ||
|
|
67cef8fcb9 | ||
|
|
4ff43ba694 | ||
|
|
efdec3ca45 | ||
|
|
5c849297d0 | ||
|
|
2baa86081d | ||
|
|
980f288149 | ||
|
|
07900769d9 | ||
|
|
360e8a19c3 | ||
|
|
e2fa0101e3 | ||
|
|
55ea86f252 | ||
|
|
f584f05489 | ||
|
|
a06ba18dcc | ||
|
|
f3e16e7db7 | ||
|
|
64afddefd9 | ||
|
|
c873713992 | ||
|
|
3751fdfa4c | ||
|
|
c824d6b78d | ||
|
|
df1752dfe0 | ||
|
|
e826e543f2 | ||
|
|
f7eecafb45 | ||
|
|
6e6cf9ee96 | ||
|
|
5b628e23e3 | ||
|
|
4a4dc8eb0f | ||
|
|
a33becad35 | ||
|
|
d60e8b6ce3 | ||
|
|
072c27fcd5 | ||
|
|
194dcc4571 | ||
|
|
51e3cfaf32 | ||
|
|
c8ab3801ec | ||
|
|
731e6dd8a2 | ||
|
|
d7066f4a2d | ||
|
|
5274de83d6 | ||
|
|
7e4dac7f31 | ||
|
|
28122dba18 | ||
|
|
93cde61946 | ||
|
|
c2dc06a99c | ||
|
|
c9930b7fda | ||
|
|
d1149454a8 | ||
|
|
36139f6200 | ||
|
|
15ac1be92b | ||
|
|
8ca30ed32c | ||
|
|
e2605bc7c2 |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"shadcn": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix failing version derivation test
|
|
||||||
5
.changeset/message-scroller-autoscroll-anchor-hold.md
Normal file
5
.changeset/message-scroller-autoscroll-anchor-hold.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@shadcn/react": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix MessageScroller abandoning a newly appended scrollAnchor placement when autoScroll is enabled, so the anchored turn holds at the reading line while the reply streams.
|
||||||
5
.changeset/message-scroller-resize-observer-loop.md
Normal file
5
.changeset/message-scroller-resize-observer-loop.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@shadcn/react": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix MessageScroller firing "ResizeObserver loop completed with undelivered notifications" during streamed content growth by coalescing ResizeObserver callbacks into requestAnimationFrame.
|
||||||
35
.github/collect-prerelease-info.js
vendored
Normal file
35
.github/collect-prerelease-info.js
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// 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
21
.github/version-script-beta.js
vendored
@@ -1,21 +0,0 @@
|
|||||||
// 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
Normal file
56
.github/workflows/browser-tests.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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: 9.0.6
|
version: 10.33.4
|
||||||
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: 9.0.6
|
version: 10.33.4
|
||||||
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 --filter=shadcn build
|
run: pnpm build:packages
|
||||||
|
|
||||||
- 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: 9.0.6
|
version: 10.33.4
|
||||||
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 --filter=shadcn build
|
run: pnpm build:packages
|
||||||
|
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
|
|||||||
100
.github/workflows/prerelease-comment.yml
vendored
100
.github/workflows/prerelease-comment.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Adapted from create-t3-app.
|
# Adapted from create-t3-app.
|
||||||
name: Write Beta Release comment
|
name: Write Prerelease comment
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
@@ -16,51 +16,79 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Write comment to the PR
|
name: Write comment to the PR
|
||||||
steps:
|
steps:
|
||||||
- name: "Comment on PR"
|
# Stable pushes and no-changeset runs upload no artifact, so a missing
|
||||||
|
# download is expected — gate the rest of the job on it succeeding.
|
||||||
|
- name: Download prerelease info
|
||||||
|
id: download
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: prerelease-info
|
||||||
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build comment
|
||||||
|
id: info
|
||||||
|
if: steps.download.outcome == 'success'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
script: |
|
script: |
|
||||||
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
const fs = require("fs");
|
||||||
owner: context.repo.owner,
|
const info = JSON.parse(fs.readFileSync("prerelease-info.json", "utf8"));
|
||||||
repo: context.repo.repo,
|
|
||||||
run_id: context.payload.workflow_run.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const artifact of allArtifacts.data.artifacts) {
|
if (!info.packages || info.packages.length === 0) {
|
||||||
// Extract the PR number and package version from the artifact name
|
core.info("No prerelease packages to comment.");
|
||||||
const match = /^npm-package-shadcn@(.*?)-pr-(\d+)/.exec(artifact.name);
|
return;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: "Comment on PR with Link"
|
const installs = info.packages
|
||||||
|
.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: ${{ env.WORKFLOW_RUN_PR }}
|
number: ${{ steps.info.outputs.pr }}
|
||||||
message: |
|
message: ${{ steps.info.outputs.body }}
|
||||||
A new prerelease is available for testing:
|
|
||||||
|
|
||||||
```sh
|
- name: Remove the prerelease label once published
|
||||||
pnpm dlx shadcn@${{ env.BETA_PACKAGE_VERSION }}
|
if: steps.info.outputs.pr
|
||||||
```
|
|
||||||
|
|
||||||
- name: "Remove the autorelease label once published"
|
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
github.rest.issues.removeLabel({
|
try {
|
||||||
owner: context.repo.owner,
|
await github.rest.issues.removeLabel({
|
||||||
repo: context.repo.repo,
|
owner: context.repo.owner,
|
||||||
issue_number: '${{ env.WORKFLOW_RUN_PR }}',
|
repo: context.repo.repo,
|
||||||
name: '🚀 autorelease',
|
issue_number: Number("${{ steps.info.outputs.pr }}"),
|
||||||
});
|
name: `release: ${{ steps.info.outputs.channel }}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info("The prerelease label was already removed.");
|
||||||
|
}
|
||||||
|
|||||||
95
.github/workflows/release.yml
vendored
95
.github/workflows/release.yml
vendored
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
run-name: ${{ github.event_name == 'pull_request' && format('Release Beta - PR {0}', github.event.number) || 'Release Stable' }}
|
run-name: ${{ github.event_name == 'pull_request' && format('Release Prerelease - PR {0}', github.event.number) || 'Release Stable' }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -15,8 +15,8 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prerelease:
|
prerelease:
|
||||||
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && contains(github.event.pull_request.labels.*.name, '🚀 autorelease') }}
|
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 Beta to NPM
|
name: Publish Prerelease to NPM
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: Preview
|
environment: Preview
|
||||||
permissions:
|
permissions:
|
||||||
@@ -24,15 +24,41 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
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
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Use PNPM
|
- name: Use PNPM
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9.0.6
|
version: 10.33.4
|
||||||
|
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -47,23 +73,49 @@ jobs:
|
|||||||
- name: Install NPM Dependencies
|
- name: Install NPM Dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Modify package.json version
|
# A snapshot prerelease needs changesets to compute versions. The
|
||||||
run: node .github/version-script-beta.js
|
# 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: Publish Beta to NPM
|
- name: No changesets to prerelease
|
||||||
run: pnpm pub:beta
|
if: steps.changesets.outputs.present == 'false'
|
||||||
|
run: echo "::notice::No changesets found on this branch; nothing to prerelease."
|
||||||
|
|
||||||
- name: get-npm-version
|
# Snapshot versions are stamped per run (timestamped), so each publish is
|
||||||
id: package-version
|
# unique and can never collide with a real release on the latest tag.
|
||||||
uses: martinbeentjes/npm-get-version-action@main
|
- name: Version snapshot
|
||||||
with:
|
if: steps.changesets.outputs.present == 'true'
|
||||||
path: packages/shadcn
|
run: pnpm exec changeset version --snapshot ${{ steps.prerelease.outputs.channel }}
|
||||||
|
|
||||||
- name: Upload packaged artifact
|
- 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
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
|
name: prerelease-info
|
||||||
path: packages/shadcn/dist/index.js
|
path: prerelease-info.json
|
||||||
|
|
||||||
release:
|
release:
|
||||||
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
|
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
|
||||||
@@ -83,7 +135,7 @@ jobs:
|
|||||||
- name: Use PNPM
|
- name: Use PNPM
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9.0.6
|
version: 10.33.4
|
||||||
|
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -98,11 +150,10 @@ jobs:
|
|||||||
- name: Install NPM Dependencies
|
- name: Install NPM Dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
# - name: Check for errors
|
# Builds every publishable package under packages/* (shadcn, @shadcn/react),
|
||||||
# run: pnpm check
|
# never apps/v4, so each dist is fresh before changeset publish.
|
||||||
|
- name: Build the packages
|
||||||
- name: Build the package
|
run: pnpm build:packages
|
||||||
run: pnpm shadcn:build
|
|
||||||
|
|
||||||
- name: Import GPG key
|
- name: Import GPG key
|
||||||
uses: crazy-max/ghaction-import-gpg@v6
|
uses: crazy-max/ghaction-import-gpg@v6
|
||||||
|
|||||||
314
.github/workflows/templates.yml
vendored
Normal file
314
.github/workflows/templates.yml
vendored
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
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"
|
||||||
38
.github/workflows/test.yml
vendored
38
.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: 9.0.6
|
version: 10.33.4
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
@@ -46,3 +46,39 @@ jobs:
|
|||||||
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
|
||||||
|
|||||||
59
.github/workflows/validate-registries.yml
vendored
59
.github/workflows/validate-registries.yml
vendored
@@ -3,54 +3,14 @@ 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
|
||||||
@@ -73,25 +33,20 @@ jobs:
|
|||||||
node <<'EOF'
|
node <<'EOF'
|
||||||
const fs = require("node:fs")
|
const fs = require("node:fs")
|
||||||
|
|
||||||
const files = [
|
const file = "apps/v4/registry/directory.json"
|
||||||
"apps/v4/public/r/registries.json",
|
|
||||||
"apps/v4/registry/directory.json",
|
|
||||||
]
|
|
||||||
const reservedNamespaces = new Set(
|
const reservedNamespaces = new Set(
|
||||||
process.env.RESERVED_NAMESPACES.split(",").filter(Boolean)
|
process.env.RESERVED_NAMESPACES.split(",").filter(Boolean)
|
||||||
)
|
)
|
||||||
|
|
||||||
function readNames(filePath) {
|
function readNames() {
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf8")).map(
|
return JSON.parse(fs.readFileSync(file, "utf8")).map(
|
||||||
(entry) => entry.name
|
(entry) => entry.name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const violations = files.flatMap((filePath) => {
|
const violations = readNames()
|
||||||
return readNames(filePath)
|
.filter((name) => reservedNamespaces.has(name))
|
||||||
.filter((name) => reservedNamespaces.has(name))
|
.map((name) => `${file}: ${name}`)
|
||||||
.map((name) => `${filePath}: ${name}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (violations.length > 0) {
|
if (violations.length > 0) {
|
||||||
console.error("Reserved registry namespaces are not allowed:")
|
console.error("Reserved registry namespaces are not allowed:")
|
||||||
@@ -108,7 +63,7 @@ jobs:
|
|||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
id: pnpm-install
|
id: pnpm-install
|
||||||
with:
|
with:
|
||||||
version: 9.0.6
|
version: 10.33.4
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -43,5 +43,13 @@ 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
|
.codex-artifacts
|
||||||
|
.tmp*
|
||||||
|
|
||||||
|
CONTEXT.md
|
||||||
|
docs/adr
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -15,6 +15,7 @@
|
|||||||
"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,6 +141,11 @@ 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
Normal file
59
RELEASING.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 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,3 +46,4 @@ next-env.d.ts
|
|||||||
.contentlayer
|
.contentlayer
|
||||||
.content-collections
|
.content-collections
|
||||||
.source
|
.source
|
||||||
|
.devtools
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { MENU_ACCENTS, type MenuAccentValue } from "@/registry/config"
|
import { MENU_ACCENTS, type MenuAccentValue } from "@/registry/config"
|
||||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||||
import {
|
import {
|
||||||
Picker,
|
Picker,
|
||||||
PickerContent,
|
PickerContent,
|
||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
PickerRadioGroup,
|
PickerRadioGroup,
|
||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function MenuAccentPicker({
|
export function MenuAccentPicker({
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@/styles/base-nova/ui/command"
|
} from "@/styles/base-nova/ui/command"
|
||||||
import { useActionMenu } from "@/app/(app)/create/hooks/use-action-menu"
|
import { useActionMenu } from "@/app/(app)/(create)/hooks/use-action-menu"
|
||||||
|
|
||||||
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"
|
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { useMounted } from "@/hooks/use-mounted"
|
import { useMounted } from "@/hooks/use-mounted"
|
||||||
import { BASE_COLORS, type BaseColorName } from "@/registry/config"
|
import { BASE_COLORS, type BaseColorName } from "@/registry/config"
|
||||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||||
import {
|
import {
|
||||||
Picker,
|
Picker,
|
||||||
PickerContent,
|
PickerContent,
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
PickerRadioGroup,
|
PickerRadioGroup,
|
||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function BaseColorPicker({
|
export function BaseColorPicker({
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
PickerRadioGroup,
|
PickerRadioGroup,
|
||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function BasePicker({
|
export function BasePicker({
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
getThemesForBaseColor,
|
getThemesForBaseColor,
|
||||||
type ChartColorName,
|
type ChartColorName,
|
||||||
} from "@/registry/config"
|
} from "@/registry/config"
|
||||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||||
import {
|
import {
|
||||||
Picker,
|
Picker,
|
||||||
PickerContent,
|
PickerContent,
|
||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerSeparator,
|
PickerSeparator,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function ChartColorPicker({
|
export function ChartColorPicker({
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -46,12 +46,6 @@ export function ChartColorPicker({
|
|||||||
[params.chartColor]
|
[params.chartColor]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!currentChartColor && availableChartColors.length > 0) {
|
|
||||||
setParams({ chartColor: availableChartColors[0].name })
|
|
||||||
}
|
|
||||||
}, [currentChartColor, availableChartColors, setParams])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/picker relative">
|
<div className="group/picker relative">
|
||||||
<Picker>
|
<Picker>
|
||||||
@@ -5,7 +5,7 @@ import * as React from "react"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||||
import { Button } from "@/styles/base-nova/ui/button"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
|
import { usePresetCode } from "@/app/(app)/(create)/hooks/use-design-system"
|
||||||
|
|
||||||
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
||||||
const presetCode = usePresetCode()
|
const presetCode = usePresetCode()
|
||||||
72
apps/v4/app/(app)/(create)/components/create-devtools.tsx
Normal file
72
apps/v4/app/(app)/(create)/components/create-devtools.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
apps/v4/app/(app)/(create)/components/create-skeleton.tsx
Normal file
15
apps/v4/app/(app)/(create)/components/create-skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { type RegistryItem } from "shadcn/schema"
|
|||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { getThemesForBaseColor, STYLES } from "@/registry/config"
|
import { getThemesForBaseColor, STYLES } from "@/registry/config"
|
||||||
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -13,30 +14,41 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
} from "@/styles/base-nova/ui/card"
|
} from "@/styles/base-nova/ui/card"
|
||||||
import { FieldGroup, FieldSeparator } from "@/styles/base-nova/ui/field"
|
import { FieldGroup, FieldSeparator } from "@/styles/base-nova/ui/field"
|
||||||
import { MenuAccentPicker } from "@/app/(app)/create/components/accent-picker"
|
import { MenuAccentPicker } from "@/app/(app)/(create)/components/accent-picker"
|
||||||
import { ActionMenu } from "@/app/(app)/create/components/action-menu"
|
import { ActionMenu } from "@/app/(app)/(create)/components/action-menu"
|
||||||
import { BaseColorPicker } from "@/app/(app)/create/components/base-color-picker"
|
import { BaseColorPicker } from "@/app/(app)/(create)/components/base-color-picker"
|
||||||
import { BasePicker } from "@/app/(app)/create/components/base-picker"
|
import { BasePicker } from "@/app/(app)/(create)/components/base-picker"
|
||||||
import { ChartColorPicker } from "@/app/(app)/create/components/chart-color-picker"
|
import { ChartColorPicker } from "@/app/(app)/(create)/components/chart-color-picker"
|
||||||
import { CopyPreset } from "@/app/(app)/create/components/copy-preset"
|
import { CopyPreset } from "@/app/(app)/(create)/components/copy-preset"
|
||||||
import { FontPicker } from "@/app/(app)/create/components/font-picker"
|
import { FontPicker } from "@/app/(app)/(create)/components/font-picker"
|
||||||
import { IconLibraryPicker } from "@/app/(app)/create/components/icon-library-picker"
|
import { IconLibraryPicker } from "@/app/(app)/(create)/components/icon-library-picker"
|
||||||
import { MainMenu } from "@/app/(app)/create/components/main-menu"
|
import { MainMenu } from "@/app/(app)/(create)/components/main-menu"
|
||||||
import { MenuColorPicker } from "@/app/(app)/create/components/menu-picker"
|
import { MenuColorPicker } from "@/app/(app)/(create)/components/menu-picker"
|
||||||
import { OpenPreset } from "@/app/(app)/create/components/open-preset"
|
import { OpenPreset } from "@/app/(app)/(create)/components/open-preset"
|
||||||
import { RadiusPicker } from "@/app/(app)/create/components/radius-picker"
|
import { RadiusPicker } from "@/app/(app)/(create)/components/radius-picker"
|
||||||
import { RandomButton } from "@/app/(app)/create/components/random-button"
|
import { RandomButton } from "@/app/(app)/(create)/components/random-button"
|
||||||
import { ResetDialog } from "@/app/(app)/create/components/reset-button"
|
import { ResetDialog } from "@/app/(app)/(create)/components/reset-button"
|
||||||
import { StylePicker } from "@/app/(app)/create/components/style-picker"
|
import { StylePicker } from "@/app/(app)/(create)/components/style-picker"
|
||||||
import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
|
import { ThemePicker } from "@/app/(app)/(create)/components/theme-picker"
|
||||||
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
|
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/(create)/lib/fonts"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
// Only visible when user clicks "Create Project".
|
// Only visible when user clicks "Create Project". Rendered client-only to
|
||||||
const ProjectForm = dynamic(() =>
|
// avoid a useId hydration mismatch on the Base UI dialog trigger. The loading
|
||||||
import("@/app/(app)/create/components/project-form").then(
|
// placeholder mirrors the trigger button exactly so there is no layout shift.
|
||||||
(m) => m.ProjectForm
|
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({
|
export function Customizer({
|
||||||
@@ -55,7 +67,7 @@ export function Customizer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="dark top-24 right-12 isolate z-10 max-h-full min-h-0 w-full self-start rounded-2xl bg-card/90 shadow-xl backdrop-blur-xl md:w-(--customizer-width)"
|
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}
|
ref={anchorRef}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
POINTER_CURSOR_SELECTOR,
|
POINTER_CURSOR_SELECTOR,
|
||||||
type DesignSystemConfig,
|
type DesignSystemConfig,
|
||||||
} from "@/registry/config"
|
} from "@/registry/config"
|
||||||
import { useIframeMessageListener } from "@/app/(app)/create/hooks/use-iframe-sync"
|
import { useIframeMessageListener } from "@/app/(app)/(create)/hooks/use-iframe-sync"
|
||||||
import { FONTS } from "@/app/(app)/create/lib/fonts"
|
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
|
||||||
import {
|
import {
|
||||||
useDesignSystemSearchParams,
|
useDesignSystemSearchParams,
|
||||||
type DesignSystemSearchParams,
|
type DesignSystemSearchParams,
|
||||||
} from "@/app/(app)/create/lib/search-params"
|
} from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
const THEME_STYLE_ELEMENT_ID = "design-system-theme-vars"
|
const THEME_STYLE_ELEMENT_ID = "design-system-theme-vars"
|
||||||
const MANAGED_BODY_CLASS_PREFIXES = ["style-", "base-color-"] as const
|
const MANAGED_BODY_CLASS_PREFIXES = ["style-", "base-color-"] as const
|
||||||
@@ -143,6 +143,11 @@ export function DesignSystemProvider({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (style === "lyra" || (style === "sera" && radius !== "none")) {
|
if (style === "lyra" || (style === "sera" && radius !== "none")) {
|
||||||
setSearchParams({ radius: "none" })
|
setSearchParams({ radius: "none" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style === "rhea" && radius === "large") {
|
||||||
|
setSearchParams({ radius: "default" })
|
||||||
}
|
}
|
||||||
}, [style, radius, setSearchParams])
|
}, [style, radius, setSearchParams])
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||||
import {
|
import {
|
||||||
Picker,
|
Picker,
|
||||||
PickerContent,
|
PickerContent,
|
||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerSeparator,
|
PickerSeparator,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { FONTS } from "@/app/(app)/create/lib/fonts"
|
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
|
||||||
import {
|
import {
|
||||||
useDesignSystemSearchParams,
|
useDesignSystemSearchParams,
|
||||||
type DesignSystemSearchParams,
|
type DesignSystemSearchParams,
|
||||||
} from "@/app/(app)/create/lib/search-params"
|
} from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
type FontPickerOption = {
|
type FontPickerOption = {
|
||||||
name: string
|
name: string
|
||||||
@@ -5,7 +5,7 @@ import { Redo02Icon, Undo02Icon } from "@hugeicons/core-free-icons"
|
|||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
import { HugeiconsIcon } from "@hugeicons/react"
|
||||||
|
|
||||||
import { Button } from "@/styles/base-nova/ui/button"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import { useHistory } from "@/app/(app)/create/hooks/use-history"
|
import { useHistory } from "@/app/(app)/(create)/hooks/use-history"
|
||||||
|
|
||||||
export const UNDO_FORWARD_TYPE = "undo-forward"
|
export const UNDO_FORWARD_TYPE = "undo-forward"
|
||||||
export const REDO_FORWARD_TYPE = "redo-forward"
|
export const REDO_FORWARD_TYPE = "redo-forward"
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { iconLibraries, type IconLibraryName } from "@/registry/config"
|
import { iconLibraries, type IconLibraryName } from "@/registry/config"
|
||||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||||
import {
|
import {
|
||||||
Picker,
|
Picker,
|
||||||
PickerContent,
|
PickerContent,
|
||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
PickerRadioGroup,
|
PickerRadioGroup,
|
||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
const logos = {
|
const logos = {
|
||||||
lucide: (
|
lucide: (
|
||||||
@@ -4,7 +4,7 @@ import { lazy, Suspense } from "react"
|
|||||||
import { SquareIcon } from "lucide-react"
|
import { SquareIcon } from "lucide-react"
|
||||||
import type { IconLibraryName } from "shadcn/icons"
|
import type { IconLibraryName } from "shadcn/icons"
|
||||||
|
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
const IconLucide = lazy(() =>
|
const IconLucide = lazy(() =>
|
||||||
import("@/registry/icons/icon-lucide").then((mod) => ({
|
import("@/registry/icons/icon-lucide").then((mod) => ({
|
||||||
@@ -21,8 +21,8 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/styles/base-nova/ui/sidebar"
|
} from "@/styles/base-nova/ui/sidebar"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
import { groupItemsByType } from "@/app/(app)/create/lib/utils"
|
import { groupItemsByType } from "@/app/(app)/(create)/lib/utils"
|
||||||
|
|
||||||
const cachedGroupedItems = React.cache(
|
const cachedGroupedItems = React.cache(
|
||||||
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {
|
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {
|
||||||
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import {
|
import {
|
||||||
useLocks,
|
useLocks,
|
||||||
type LockableParam,
|
type LockableParam,
|
||||||
} from "@/app/(app)/create/hooks/use-locks"
|
} from "@/app/(app)/(create)/hooks/use-locks"
|
||||||
|
|
||||||
export function LockButton({
|
export function LockButton({
|
||||||
param,
|
param,
|
||||||
@@ -14,13 +14,13 @@ import {
|
|||||||
PickerSeparator,
|
PickerSeparator,
|
||||||
PickerShortcut,
|
PickerShortcut,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { useActionMenuTrigger } from "@/app/(app)/create/hooks/use-action-menu"
|
import { useActionMenuTrigger } from "@/app/(app)/(create)/hooks/use-action-menu"
|
||||||
import { useHistory } from "@/app/(app)/create/hooks/use-history"
|
import { useHistory } from "@/app/(app)/(create)/hooks/use-history"
|
||||||
import { useOpenPresetTrigger } from "@/app/(app)/create/hooks/use-open-preset"
|
import { useOpenPresetTrigger } from "@/app/(app)/(create)/hooks/use-open-preset"
|
||||||
import { useRandom } from "@/app/(app)/create/hooks/use-random"
|
import { useRandom } from "@/app/(app)/(create)/hooks/use-random"
|
||||||
import { useReset } from "@/app/(app)/create/hooks/use-reset"
|
import { useReset } from "@/app/(app)/(create)/hooks/use-reset"
|
||||||
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
|
import { useThemeToggle } from "@/app/(app)/(create)/hooks/use-theme-toggle"
|
||||||
|
|
||||||
const APPLE_PLATFORM_REGEX = /Mac|iPhone|iPad|iPod/
|
const APPLE_PLATFORM_REGEX = /Mac|iPhone|iPad|iPod/
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import { useTheme } from "next-themes"
|
|||||||
|
|
||||||
import { useMounted } from "@/hooks/use-mounted"
|
import { useMounted } from "@/hooks/use-mounted"
|
||||||
import { type MenuColorValue } from "@/registry/config"
|
import { type MenuColorValue } from "@/registry/config"
|
||||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||||
import {
|
import {
|
||||||
Picker,
|
Picker,
|
||||||
PickerContent,
|
PickerContent,
|
||||||
@@ -17,11 +17,11 @@ import {
|
|||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerSeparator,
|
PickerSeparator,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import {
|
import {
|
||||||
isTranslucentMenuColor,
|
isTranslucentMenuColor,
|
||||||
useDesignSystemSearchParams,
|
useDesignSystemSearchParams,
|
||||||
} from "@/app/(app)/create/lib/search-params"
|
} from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
type ColorChoice = "default" | "inverted"
|
type ColorChoice = "default" | "inverted"
|
||||||
type SurfaceChoice = "solid" | "translucent"
|
type SurfaceChoice = "solid" | "translucent"
|
||||||
@@ -5,7 +5,7 @@ import Script from "next/script"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/styles/base-nova/ui/button"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
|
import { useThemeToggle } from "@/app/(app)/(create)/hooks/use-theme-toggle"
|
||||||
|
|
||||||
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
|
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
|
||||||
|
|
||||||
@@ -31,9 +31,9 @@ import { Input } from "@/styles/base-nova/ui/input"
|
|||||||
import {
|
import {
|
||||||
OPEN_PRESET_FORWARD_TYPE,
|
OPEN_PRESET_FORWARD_TYPE,
|
||||||
useOpenPreset,
|
useOpenPreset,
|
||||||
} from "@/app/(app)/create/hooks/use-open-preset"
|
} from "@/app/(app)/(create)/hooks/use-open-preset"
|
||||||
import { parsePresetInput } from "@/app/(app)/create/lib/parse-preset-input"
|
import { parsePresetInput } from "@/app/(app)/(create)/lib/parse-preset-input"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
const PRESET_EXAMPLE = "b2D0wqNxT"
|
const PRESET_EXAMPLE = "b2D0wqNxT"
|
||||||
const PRESET_TITLE = "Open Preset"
|
const PRESET_TITLE = "Open Preset"
|
||||||
@@ -111,11 +111,12 @@ export function OpenPreset({
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger
|
||||||
<Button variant="outline" className={triggerClassName}>
|
render={<Button variant="outline" className={triggerClassName} />}
|
||||||
{label}
|
>
|
||||||
</Button>
|
{label}
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
|
|
||||||
<DrawerContent className="dark rounded-t-2xl!">
|
<DrawerContent className="dark rounded-t-2xl!">
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
|
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
|
||||||
@@ -127,10 +128,12 @@ export function OpenPreset({
|
|||||||
<Button type="submit" className="h-10" disabled={!nextPreset}>
|
<Button type="submit" className="h-10" disabled={!nextPreset}>
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
<DrawerClose asChild>
|
<DrawerClose
|
||||||
<Button variant="outline" type="button" className="h-10">
|
render={
|
||||||
Cancel
|
<Button variant="outline" type="button" className="h-10" />
|
||||||
</Button>
|
}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</DrawerClose>
|
</DrawerClose>
|
||||||
</DrawerFooter>
|
</DrawerFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
import { cn } from "@/registry/bases/base/lib/utils"
|
import { cn } from "@/registry/bases/base/lib/utils"
|
||||||
import { IconPlaceholder } from "@/app/(app)/create/components/icon-placeholder"
|
import { IconPlaceholder } from "@/app/(app)/(create)/components/icon-placeholder"
|
||||||
|
|
||||||
function Picker({ ...props }: MenuPrimitive.Root.Props) {
|
function Picker({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { generateRandomPreset, isPresetCode } from "shadcn/preset"
|
import { generateRandomPreset, isPresetCode } from "shadcn/preset"
|
||||||
|
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function PresetHandler() {
|
export function PresetHandler() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
PickerRadioGroup,
|
PickerRadioGroup,
|
||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function PresetPicker({
|
export function PresetPicker({
|
||||||
presets,
|
presets,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
const PREVIEW_ITEMS = [
|
const PREVIEW_ITEMS = [
|
||||||
{ label: "01", value: "preview-02" },
|
{ label: "01", value: "preview-02" },
|
||||||
@@ -2,21 +2,22 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/create/components/action-menu"
|
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/(create)/components/action-menu"
|
||||||
|
import { CreateDevtools } from "@/app/(app)/(create)/components/create-devtools"
|
||||||
import {
|
import {
|
||||||
REDO_FORWARD_TYPE,
|
REDO_FORWARD_TYPE,
|
||||||
UNDO_FORWARD_TYPE,
|
UNDO_FORWARD_TYPE,
|
||||||
} from "@/app/(app)/create/components/history-buttons"
|
} from "@/app/(app)/(create)/components/history-buttons"
|
||||||
import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/create/components/mode-switcher"
|
import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/(create)/components/mode-switcher"
|
||||||
import { PreviewSwitcher } from "@/app/(app)/create/components/preview-switcher"
|
import { PreviewSwitcher } from "@/app/(app)/(create)/components/preview-switcher"
|
||||||
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/create/components/random-button"
|
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/(create)/components/random-button"
|
||||||
import { sendToIframe } from "@/app/(app)/create/hooks/use-iframe-sync"
|
import { sendToIframe } from "@/app/(app)/(create)/hooks/use-iframe-sync"
|
||||||
import { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-open-preset"
|
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 { RESET_FORWARD_TYPE } from "@/app/(app)/(create)/hooks/use-reset"
|
||||||
import {
|
import {
|
||||||
serializeDesignSystemSearchParams,
|
serializeDesignSystemSearchParams,
|
||||||
useDesignSystemSearchParams,
|
useDesignSystemSearchParams,
|
||||||
} from "@/app/(app)/create/lib/search-params"
|
} from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
|
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
|
||||||
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
|
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
|
||||||
@@ -160,6 +161,7 @@ export function Preview() {
|
|||||||
title="Preview"
|
title="Preview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<CreateDevtools />
|
||||||
<PreviewSwitcher />
|
<PreviewSwitcher />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
726
apps/v4/app/(app)/(create)/components/project-form.tsx
Normal file
726
apps/v4/app/(app)/(create)/components/project-form.tsx
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { RADII, type RadiusValue } from "@/registry/config"
|
import { RADII, type RadiusValue } from "@/registry/config"
|
||||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||||
import {
|
import {
|
||||||
Picker,
|
Picker,
|
||||||
PickerContent,
|
PickerContent,
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerSeparator,
|
PickerSeparator,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function RadiusPicker({
|
export function RadiusPicker({
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -93,6 +93,7 @@ export function RadiusPicker({
|
|||||||
key={radius.name}
|
key={radius.name}
|
||||||
value={radius.name}
|
value={radius.name}
|
||||||
closeOnClick={isMobile}
|
closeOnClick={isMobile}
|
||||||
|
disabled={params.style === "rhea" && radius.name === "large"}
|
||||||
>
|
>
|
||||||
{radius.label}
|
{radius.label}
|
||||||
</PickerRadioItem>
|
</PickerRadioItem>
|
||||||
@@ -6,8 +6,8 @@ import { HugeiconsIcon } from "@hugeicons/react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/styles/base-nova/ui/button"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import { useRandom } from "@/app/(app)/create/hooks/use-random"
|
import { useRandom } from "@/app/(app)/(create)/hooks/use-random"
|
||||||
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
|
import { RESET_FORWARD_TYPE } from "@/app/(app)/(create)/hooks/use-reset"
|
||||||
|
|
||||||
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
|
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/styles/base-nova/ui/alert-dialog"
|
} from "@/styles/base-nova/ui/alert-dialog"
|
||||||
import { useReset } from "@/app/(app)/create/hooks/use-reset"
|
import { useReset } from "@/app/(app)/(create)/hooks/use-reset"
|
||||||
|
|
||||||
export function ResetDialog() {
|
export function ResetDialog() {
|
||||||
const { showResetDialog, setShowResetDialog, confirmReset } = useReset()
|
const { showResetDialog, setShowResetDialog, confirmReset } = useReset()
|
||||||
@@ -6,8 +6,8 @@ import { HugeiconsIcon } from "@hugeicons/react"
|
|||||||
|
|
||||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||||
import { Button } from "@/styles/base-nova/ui/button"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
|
import { usePresetCode } from "@/app/(app)/(create)/hooks/use-design-system"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function ShareButton() {
|
export function ShareButton() {
|
||||||
const [params] = useDesignSystemSearchParams()
|
const [params] = useDesignSystemSearchParams()
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { PRESETS, type Style, type StyleName } from "@/registry/config"
|
import { type Style, type StyleName } from "@/registry/config"
|
||||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||||
import {
|
import {
|
||||||
Picker,
|
Picker,
|
||||||
PickerContent,
|
PickerContent,
|
||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
PickerRadioGroup,
|
PickerRadioGroup,
|
||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function StylePicker({
|
export function StylePicker({
|
||||||
styles,
|
styles,
|
||||||
@@ -53,24 +53,7 @@ export function StylePicker({
|
|||||||
<PickerRadioGroup
|
<PickerRadioGroup
|
||||||
value={currentStyle?.name}
|
value={currentStyle?.name}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const styleName = value as StyleName
|
setParams({ style: value as StyleName })
|
||||||
const preset = PRESETS.find(
|
|
||||||
(p) => p.base === params.base && p.style === styleName
|
|
||||||
)
|
|
||||||
setParams({
|
|
||||||
style: styleName,
|
|
||||||
...(preset && {
|
|
||||||
baseColor: preset.baseColor,
|
|
||||||
theme: preset.theme,
|
|
||||||
chartColor: preset.chartColor,
|
|
||||||
iconLibrary: preset.iconLibrary,
|
|
||||||
font: preset.font,
|
|
||||||
fontHeading: preset.fontHeading,
|
|
||||||
menuAccent: preset.menuAccent,
|
|
||||||
menuColor: preset.menuColor,
|
|
||||||
radius: preset.radius,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PickerGroup>
|
<PickerGroup>
|
||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { useMounted } from "@/hooks/use-mounted"
|
import { useMounted } from "@/hooks/use-mounted"
|
||||||
import { BASE_COLORS, type Theme, type ThemeName } from "@/registry/config"
|
import { BASE_COLORS, type Theme, type ThemeName } from "@/registry/config"
|
||||||
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||||
import {
|
import {
|
||||||
Picker,
|
Picker,
|
||||||
PickerContent,
|
PickerContent,
|
||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
PickerRadioItem,
|
PickerRadioItem,
|
||||||
PickerSeparator,
|
PickerSeparator,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(app)/create/components/picker"
|
} from "@/app/(app)/(create)/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function ThemePicker({
|
export function ThemePicker({
|
||||||
themes,
|
themes,
|
||||||
@@ -38,12 +38,6 @@ export function ThemePicker({
|
|||||||
[params.theme]
|
[params.theme]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!currentTheme && themes.length > 0) {
|
|
||||||
setParams({ theme: themes[0].name })
|
|
||||||
}
|
|
||||||
}, [currentTheme, themes, setParams])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/picker relative">
|
<div className="group/picker relative">
|
||||||
<Picker>
|
<Picker>
|
||||||
@@ -8,7 +8,7 @@ import { useMounted } from "@/hooks/use-mounted"
|
|||||||
import { Icons } from "@/components/icons"
|
import { Icons } from "@/components/icons"
|
||||||
import { Button } from "@/styles/base-nova/ui/button"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
export function V0Button({ className }: { className?: string }) {
|
export function V0Button({ className }: { className?: string }) {
|
||||||
const [params] = useDesignSystemSearchParams()
|
const [params] = useDesignSystemSearchParams()
|
||||||
22
apps/v4/app/(app)/(create)/create/layout.tsx
Normal file
22
apps/v4/app/(app)/(create)/create/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/v4/app/(app)/(create)/create/loading.tsx
Normal file
5
apps/v4/app/(app)/(create)/create/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CreateSkeleton } from "@/app/(app)/(create)/components/create-skeleton"
|
||||||
|
|
||||||
|
export default function CreateLoading() {
|
||||||
|
return <CreateSkeleton />
|
||||||
|
}
|
||||||
@@ -5,14 +5,14 @@ import dynamic from "next/dynamic"
|
|||||||
import { siteConfig } from "@/lib/config"
|
import { siteConfig } from "@/lib/config"
|
||||||
import { absoluteUrl } from "@/lib/utils"
|
import { absoluteUrl } from "@/lib/utils"
|
||||||
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
||||||
import { Customizer } from "@/app/(app)/create/components/customizer"
|
import { Customizer } from "@/app/(app)/(create)/components/customizer"
|
||||||
import { PresetHandler } from "@/app/(app)/create/components/preset-handler"
|
import { PresetHandler } from "@/app/(app)/(create)/components/preset-handler"
|
||||||
import { Preview } from "@/app/(app)/create/components/preview"
|
import { Preview } from "@/app/(app)/(create)/components/preview"
|
||||||
import { getAllItems } from "@/app/(app)/create/lib/api"
|
import { getAllItems } from "@/app/(app)/(create)/lib/api"
|
||||||
|
|
||||||
// Only shown on first visit (checks localStorage).
|
// Only shown on first visit (checks localStorage).
|
||||||
const WelcomeDialog = dynamic(() =>
|
const WelcomeDialog = dynamic(() =>
|
||||||
import("@/app/(app)/create/components/welcome-dialog").then(
|
import("@/app/(app)/(create)/components/welcome-dialog").then(
|
||||||
(m) => m.WelcomeDialog
|
(m) => m.WelcomeDialog
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -4,8 +4,8 @@ import * as React from "react"
|
|||||||
import { type RegistryItem } from "shadcn/schema"
|
import { type RegistryItem } from "shadcn/schema"
|
||||||
import useSWR from "swr"
|
import useSWR from "swr"
|
||||||
|
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
import { groupItemsByType } from "@/app/(app)/create/lib/utils"
|
import { groupItemsByType } from "@/app/(app)/(create)/lib/utils"
|
||||||
|
|
||||||
const ACTION_MENU_OPEN_KEY = "create:action-menu-open"
|
const ACTION_MENU_OPEN_KEY = "create:action-menu-open"
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
|
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
// Returns the canonical preset code derived from the current search params.
|
// Returns the canonical preset code derived from the current search params.
|
||||||
export function usePresetCode() {
|
export function usePresetCode() {
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
|
import {
|
||||||
|
useDesignSystemSearchParams,
|
||||||
|
type DesignSystemSearchParams,
|
||||||
|
} from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
type HistoryContextValue = {
|
type HistoryContextValue = {
|
||||||
canGoBack: boolean
|
canGoBack: boolean
|
||||||
@@ -15,6 +20,9 @@ const HistoryContext = React.createContext<HistoryContextValue | null>(null)
|
|||||||
|
|
||||||
// Reads useSearchParams() in its own Suspense boundary so the
|
// Reads useSearchParams() in its own Suspense boundary so the
|
||||||
// provider never blanks out children while search params resolve.
|
// 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({
|
function PresetSync({
|
||||||
onPresetChange,
|
onPresetChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -31,7 +39,11 @@ function PresetSync({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
||||||
const router = useRouter()
|
// 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 [preset, setPreset] = React.useState("")
|
||||||
|
|
||||||
@@ -81,17 +93,16 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
indexRef.current = nextIndex
|
indexRef.current = nextIndex
|
||||||
setIndex(nextIndex)
|
setIndex(nextIndex)
|
||||||
|
|
||||||
const targetPreset = entriesRef.current[nextIndex]
|
// The first history entry is "" (no preset in the URL); null clears the
|
||||||
const params = new URLSearchParams(window.location.search)
|
// param to restore that state. nuqs accepts null to clear a key, which the
|
||||||
if (targetPreset) {
|
// wrapper's input type does not model.
|
||||||
params.set("preset", targetPreset)
|
setParams(
|
||||||
} else {
|
{
|
||||||
params.delete("preset")
|
preset: entriesRef.current[nextIndex] || null,
|
||||||
}
|
} as Partial<DesignSystemSearchParams>,
|
||||||
const pathname = window.location.pathname
|
{ history: "replace" }
|
||||||
const query = params.toString()
|
)
|
||||||
router.replace(query ? `${pathname}?${query}` : pathname)
|
}, [setParams])
|
||||||
}, [router])
|
|
||||||
|
|
||||||
const goForward = React.useCallback(() => {
|
const goForward = React.useCallback(() => {
|
||||||
if (indexRef.current >= maxIndexRef.current) {
|
if (indexRef.current >= maxIndexRef.current) {
|
||||||
@@ -103,17 +114,16 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
indexRef.current = nextIndex
|
indexRef.current = nextIndex
|
||||||
setIndex(nextIndex)
|
setIndex(nextIndex)
|
||||||
|
|
||||||
const targetPreset = entriesRef.current[nextIndex]
|
// The first history entry is "" (no preset in the URL); null clears the
|
||||||
const params = new URLSearchParams(window.location.search)
|
// param to restore that state. nuqs accepts null to clear a key, which the
|
||||||
if (targetPreset) {
|
// wrapper's input type does not model.
|
||||||
params.set("preset", targetPreset)
|
setParams(
|
||||||
} else {
|
{
|
||||||
params.delete("preset")
|
preset: entriesRef.current[nextIndex] || null,
|
||||||
}
|
} as Partial<DesignSystemSearchParams>,
|
||||||
const pathname = window.location.pathname
|
{ history: "replace" }
|
||||||
const query = params.toString()
|
)
|
||||||
router.replace(query ? `${pathname}?${query}` : pathname)
|
}, [setParams])
|
||||||
}, [router])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import type { DesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import type { DesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
type ParentToIframeMessage = {
|
type ParentToIframeMessage = {
|
||||||
type: "design-system-params"
|
type: "design-system-params"
|
||||||
@@ -24,15 +24,10 @@ const LocksContext = React.createContext<LocksContextValue | null>(null)
|
|||||||
|
|
||||||
export function LocksProvider({ children }: { children: React.ReactNode }) {
|
export function LocksProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [locks, setLocks] = React.useState<Set<LockableParam>>(new Set())
|
const [locks, setLocks] = React.useState<Set<LockableParam>>(new Set())
|
||||||
const locksRef = React.useRef(locks)
|
|
||||||
React.useEffect(() => {
|
|
||||||
locksRef.current = locks
|
|
||||||
}, [locks])
|
|
||||||
|
|
||||||
// Stable callback — reads from ref so it doesn't change on every lock toggle.
|
|
||||||
const isLocked = React.useCallback(
|
const isLocked = React.useCallback(
|
||||||
(param: LockableParam) => locksRef.current.has(param),
|
(param: LockableParam) => locks.has(param),
|
||||||
[]
|
[locks]
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleLock = React.useCallback((param: LockableParam) => {
|
const toggleLock = React.useCallback((param: LockableParam) => {
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { decodePreset } from "shadcn/preset"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BASE_COLORS,
|
BASE_COLORS,
|
||||||
@@ -10,19 +11,30 @@ import {
|
|||||||
MENU_COLORS,
|
MENU_COLORS,
|
||||||
RADII,
|
RADII,
|
||||||
STYLES,
|
STYLES,
|
||||||
|
type BaseColorName,
|
||||||
|
type ChartColorName,
|
||||||
type FontHeadingValue,
|
type FontHeadingValue,
|
||||||
|
type FontValue,
|
||||||
|
type IconLibraryName,
|
||||||
|
type MenuAccentValue,
|
||||||
|
type MenuColorValue,
|
||||||
|
type RadiusValue,
|
||||||
|
type StyleName,
|
||||||
|
type ThemeName,
|
||||||
} from "@/registry/config"
|
} from "@/registry/config"
|
||||||
import { useLocks } from "@/app/(app)/create/hooks/use-locks"
|
import { useLocks } from "@/app/(app)/(create)/hooks/use-locks"
|
||||||
import { FONTS } from "@/app/(app)/create/lib/fonts"
|
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
|
||||||
|
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
||||||
import {
|
import {
|
||||||
applyBias,
|
applyBias,
|
||||||
RANDOMIZE_BIASES,
|
RANDOMIZE_BIASES,
|
||||||
type RandomizeContext,
|
type RandomizeContext,
|
||||||
} from "@/app/(app)/create/lib/randomize-biases"
|
} from "@/app/(app)/(create)/lib/randomize-biases"
|
||||||
import {
|
import {
|
||||||
isTranslucentMenuColor,
|
isTranslucentMenuColor,
|
||||||
useDesignSystemSearchParams,
|
useDesignSystemSearchParams,
|
||||||
} from "@/app/(app)/create/lib/search-params"
|
} from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
import { SHUFFLE_PRESETS } from "@/app/(app)/(create)/lib/shuffle-presets"
|
||||||
|
|
||||||
function randomItem<T>(array: readonly T[]): T {
|
function randomItem<T>(array: readonly T[]): T {
|
||||||
return array[Math.floor(Math.random() * array.length)]
|
return array[Math.floor(Math.random() * array.length)]
|
||||||
@@ -37,7 +49,9 @@ export function useRandom() {
|
|||||||
paramsRef.current = params
|
paramsRef.current = params
|
||||||
}, [params])
|
}, [params])
|
||||||
|
|
||||||
const randomize = React.useCallback(() => {
|
// True-random implementation. Kept, but shuffle is now wired to the
|
||||||
|
// curated preset list below.
|
||||||
|
const randomizeTrueRandom = React.useCallback(() => {
|
||||||
const selectedStyle = locks.has("style")
|
const selectedStyle = locks.has("style")
|
||||||
? paramsRef.current.style
|
? paramsRef.current.style
|
||||||
: randomItem(STYLES).name
|
: randomItem(STYLES).name
|
||||||
@@ -149,6 +163,60 @@ export function useRandom() {
|
|||||||
setParams(nextParams)
|
setParams(nextParams)
|
||||||
}, [setParams, locks])
|
}, [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)
|
const randomizeRef = React.useRef(randomize)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
randomizeRef.current = randomize
|
randomizeRef.current = randomize
|
||||||
@@ -177,5 +245,5 @@ export function useRandom() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { randomize }
|
return { randomize, randomizeTrueRandom }
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
import useSWR from "swr"
|
import useSWR from "swr"
|
||||||
|
|
||||||
import { DEFAULT_CONFIG, PRESETS } from "@/registry/config"
|
import { DEFAULT_CONFIG, PRESETS } from "@/registry/config"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||||
|
|
||||||
const RESET_DIALOG_KEY = "create:reset-dialog-open"
|
const RESET_DIALOG_KEY = "create:reset-dialog-open"
|
||||||
export const RESET_FORWARD_TYPE = "reset-forward"
|
export const RESET_FORWARD_TYPE = "reset-forward"
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { type NextRequest } from "next/server"
|
import { type NextRequest } from "next/server"
|
||||||
import { track } from "@vercel/analytics/server"
|
import { track } from "@vercel/analytics/server"
|
||||||
|
|
||||||
import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config"
|
import { buildInstructions } from "@/app/(app)/(create)/lib/build-instructions"
|
||||||
|
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
|
||||||
import { buildInstructions } from "./build-instructions"
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
buildRegistryBase,
|
buildRegistryBase,
|
||||||
parseRegistryBaseParts,
|
parseRegistryBaseParts,
|
||||||
} from "@/registry/config"
|
} from "@/registry/config"
|
||||||
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
|
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
|
||||||
import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config"
|
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -2,9 +2,9 @@ import { after, NextResponse, type NextRequest } from "next/server"
|
|||||||
import { track } from "@vercel/analytics/server"
|
import { track } from "@vercel/analytics/server"
|
||||||
import { isPresetCode } from "shadcn/preset"
|
import { isPresetCode } from "shadcn/preset"
|
||||||
|
|
||||||
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
|
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
|
||||||
import { buildV0Payload } from "@/app/(app)/create/lib/v0"
|
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
||||||
import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config"
|
import { buildV0Payload } from "@/app/(app)/(create)/lib/v0"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -6,7 +6,7 @@ import { BASES, getThemesForBaseColor, type BaseName } from "@/registry/config"
|
|||||||
import {
|
import {
|
||||||
ALLOWED_ITEM_TYPES,
|
ALLOWED_ITEM_TYPES,
|
||||||
EXCLUDED_ITEMS,
|
EXCLUDED_ITEMS,
|
||||||
} from "@/app/(app)/create/lib/constants"
|
} from "@/app/(app)/(create)/lib/constants"
|
||||||
|
|
||||||
export async function getItemsForBase(base: BaseName) {
|
export async function getItemsForBase(base: BaseName) {
|
||||||
const { Index } = await import("@/registry/bases/__index__")
|
const { Index } = await import("@/registry/bases/__index__")
|
||||||
@@ -42,7 +42,8 @@ export async function getBaseComponent(name: string, base: BaseName) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return index[name].component
|
const { Components } = await import("@/registry/bases/__components__")
|
||||||
|
return Components[base]?.[name] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllItems() {
|
export async function getAllItems() {
|
||||||
10
apps/v4/app/(app)/(create)/lib/devtools.ts
Normal file
10
apps/v4/app/(app)/(create)/lib/devtools.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
77
apps/v4/app/(app)/(create)/lib/fonts.ts
Normal file
77
apps/v4/app/(app)/(create)/lib/fonts.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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]
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
designSystemConfigSchema,
|
designSystemConfigSchema,
|
||||||
type DesignSystemConfig,
|
type DesignSystemConfig,
|
||||||
} from "@/registry/config"
|
} from "@/registry/config"
|
||||||
import { resolvePresetOverrides } from "@/app/(app)/create/lib/preset-query"
|
import { resolvePresetOverrides } from "@/app/(app)/(create)/lib/preset-query"
|
||||||
|
|
||||||
// Parses design system config from URL search params.
|
// Parses design system config from URL search params.
|
||||||
export function parseDesignSystemConfig(searchParams: URLSearchParams) {
|
export function parseDesignSystemConfig(searchParams: URLSearchParams) {
|
||||||
@@ -20,7 +20,7 @@ export function parseDesignSystemConfig(searchParams: URLSearchParams) {
|
|||||||
configInput = {
|
configInput = {
|
||||||
...decoded,
|
...decoded,
|
||||||
...presetOverrides,
|
...presetOverrides,
|
||||||
base: searchParams.get("base") ?? "radix",
|
base: searchParams.get("base") ?? "base",
|
||||||
template: searchParams.get("template") ?? undefined,
|
template: searchParams.get("template") ?? undefined,
|
||||||
rtl: searchParams.get("rtl") === "true",
|
rtl: searchParams.get("rtl") === "true",
|
||||||
pointer: searchParams.get("pointer") === "true",
|
pointer: searchParams.get("pointer") === "true",
|
||||||
@@ -76,6 +76,11 @@ export const RANDOMIZE_BIASES: RandomizeBiases = {
|
|||||||
return radii.filter((radius) => radius.name === "none")
|
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
|
return radii
|
||||||
},
|
},
|
||||||
chartColors: (chartColors, context) => {
|
chartColors: (chartColors, context) => {
|
||||||
53
apps/v4/app/(app)/(create)/lib/search-params.test.ts
Normal file
53
apps/v4/app/(app)/(create)/lib/search-params.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -36,9 +36,9 @@ import {
|
|||||||
type StyleName,
|
type StyleName,
|
||||||
type ThemeName,
|
type ThemeName,
|
||||||
} from "@/registry/config"
|
} from "@/registry/config"
|
||||||
import { FONTS } from "@/app/(app)/create/lib/fonts"
|
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
|
||||||
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
|
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
||||||
import { resolvePresetOverrides } from "@/app/(app)/create/lib/preset-query"
|
import { resolvePresetOverrides } from "@/app/(app)/(create)/lib/preset-query"
|
||||||
|
|
||||||
const designSystemSearchParams = {
|
const designSystemSearchParams = {
|
||||||
preset: parseAsString.withDefault("b0"),
|
preset: parseAsString.withDefault("b0"),
|
||||||
@@ -132,6 +132,9 @@ const NON_DESIGN_SYSTEM_KEYS = [
|
|||||||
"custom",
|
"custom",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
// Shared across hook instances so only one mount effect encodes the initial URL.
|
||||||
|
let hasSyncedPresetToUrl = false
|
||||||
|
|
||||||
export const loadDesignSystemSearchParams = createLoader(
|
export const loadDesignSystemSearchParams = createLoader(
|
||||||
designSystemSearchParams
|
designSystemSearchParams
|
||||||
)
|
)
|
||||||
@@ -235,13 +238,42 @@ function resolvePresetParams(
|
|||||||
return normalizeDesignSystemParams(rawParams)
|
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.
|
// Wraps nuqs useQueryStates with transparent preset encoding/decoding.
|
||||||
// - Reads: if ?preset=CODE is in the URL, decodes it and returns individual values.
|
// - 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.
|
// - 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 = {}) {
|
export function useDesignSystemSearchParams(options: Options = {}) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const [rawParams, rawSetParams] = useQueryStates(designSystemSearchParams, {
|
const [rawParams, rawSetParams] = useQueryStates(designSystemSearchParams, {
|
||||||
shallow: false,
|
shallow: true,
|
||||||
history: "push",
|
history: "push",
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
@@ -257,6 +289,19 @@ export function useDesignSystemSearchParams(options: Options = {}) {
|
|||||||
paramsRef.current = params
|
paramsRef.current = params
|
||||||
}, [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]
|
type RawSetParamsInput = Parameters<typeof rawSetParams>[0]
|
||||||
|
|
||||||
const setParams = React.useCallback(
|
const setParams = React.useCallback(
|
||||||
@@ -286,24 +331,10 @@ export function useDesignSystemSearchParams(options: Options = {}) {
|
|||||||
...paramsRef.current,
|
...paramsRef.current,
|
||||||
...resolvedUpdates,
|
...resolvedUpdates,
|
||||||
})
|
})
|
||||||
// Encode design system fields into a preset code.
|
return rawSetParams(
|
||||||
// Cast needed: merged values may include null from nuqs resets,
|
buildPresetUrlUpdate(merged, resolvedUpdates) as RawSetParamsInput,
|
||||||
// but encodePreset handles missing values by falling back to defaults.
|
setOptions
|
||||||
const code = getPresetCode(merged)
|
)
|
||||||
// Build update: set preset, clear individual DS params from URL.
|
|
||||||
const rawUpdate: Record<string, unknown> = { preset: code }
|
|
||||||
for (const key of DESIGN_SYSTEM_KEYS) {
|
|
||||||
rawUpdate[key] = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass through non-DS params that were explicitly in the update.
|
|
||||||
for (const key of NON_DESIGN_SYSTEM_KEYS) {
|
|
||||||
if (key in resolvedUpdates) {
|
|
||||||
rawUpdate[key] = (resolvedUpdates as Record<string, unknown>)[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawSetParams(rawUpdate as RawSetParamsInput, setOptions)
|
|
||||||
},
|
},
|
||||||
[rawSetParams]
|
[rawSetParams]
|
||||||
)
|
)
|
||||||
28
apps/v4/app/(app)/(create)/lib/shuffle-presets.ts
Normal file
28
apps/v4/app/(app)/(create)/lib/shuffle-presets.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// 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,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
import { DEFAULT_CONFIG } from "@/registry/config"
|
import { DEFAULT_CONFIG } from "@/registry/config"
|
||||||
import { buildV0Payload } from "@/app/(app)/create/lib/v0"
|
import { buildV0Payload } from "@/app/(app)/(create)/lib/v0"
|
||||||
|
|
||||||
vi.mock("shadcn/schema", async () => {
|
vi.mock("shadcn/schema", async () => {
|
||||||
return await vi.importActual("shadcn/schema")
|
return await vi.importActual("shadcn/schema")
|
||||||
94
apps/v4/app/(app)/(root)/cards/account-access.tsx
Normal file
94
apps/v4/app/(app)/(root)/cards/account-access.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
apps/v4/app/(app)/(root)/cards/analytics-card.tsx
Normal file
46
apps/v4/app/(app)/(root)/cards/analytics-card.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
apps/v4/app/(app)/(root)/cards/claimable-balance.tsx
Normal file
75
apps/v4/app/(app)/(root)/cards/claimable-balance.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
apps/v4/app/(app)/(root)/cards/contribution-history.tsx
Normal file
88
apps/v4/app/(app)/(root)/cards/contribution-history.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
apps/v4/app/(app)/(root)/cards/dividend-income.tsx
Normal file
116
apps/v4/app/(app)/(root)/cards/dividend-income.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
apps/v4/app/(app)/(root)/cards/empty-distribute-track.tsx
Normal file
37
apps/v4/app/(app)/(root)/cards/empty-distribute-track.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
122
apps/v4/app/(app)/(root)/cards/index.tsx
Normal file
122
apps/v4/app/(app)/(root)/cards/index.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
apps/v4/app/(app)/(root)/cards/new-milestone.tsx
Normal file
52
apps/v4/app/(app)/(root)/cards/new-milestone.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
apps/v4/app/(app)/(root)/cards/notification-settings.tsx
Normal file
76
apps/v4/app/(app)/(root)/cards/notification-settings.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
apps/v4/app/(app)/(root)/cards/payments.tsx
Normal file
139
apps/v4/app/(app)/(root)/cards/payments.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
apps/v4/app/(app)/(root)/cards/payout-threshold.tsx
Normal file
112
apps/v4/app/(app)/(root)/cards/payout-threshold.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user