mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-15 11:51:34 +00:00
Compare commits
258 Commits
shadcn/cod
...
shadcn@4.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1137b24a97 | ||
|
|
bb251e2ab6 | ||
|
|
28b3e5f360 | ||
|
|
309d95017f | ||
|
|
eb42ae25fd | ||
|
|
3977fb9ace | ||
|
|
7865621397 | ||
|
|
b07070cd07 | ||
|
|
ad68a44717 | ||
|
|
56161142f1 | ||
|
|
c2e1a5793f | ||
|
|
ea6086cbcc | ||
|
|
68a69d81f7 | ||
|
|
55fd4dc71b | ||
|
|
6dea65ebcb | ||
|
|
ba10089b8d | ||
|
|
8a814f926b | ||
|
|
c236d0c009 | ||
|
|
fd0e0c369b | ||
|
|
07d14abde1 | ||
|
|
8dd51c49f8 | ||
|
|
c20e0cc596 | ||
|
|
0126502236 | ||
|
|
94074e4bb2 | ||
|
|
eb6e783fb3 | ||
|
|
f785bfab44 | ||
|
|
cc20c8a794 | ||
|
|
05948dce8e | ||
|
|
5d23df4e35 | ||
|
|
abbdd32953 | ||
|
|
3f14ffa632 | ||
|
|
5927f6de80 | ||
|
|
39eb34104b | ||
|
|
7cbc7e8d53 | ||
|
|
d0ac558ce2 | ||
|
|
bc0c46a93c | ||
|
|
a64575d8a4 | ||
|
|
5d0cd7819b | ||
|
|
13478b26b6 | ||
|
|
aee8a71679 | ||
|
|
4507f1c794 | ||
|
|
81cd2266aa | ||
|
|
cf756b1b55 | ||
|
|
5e61f9c4a4 | ||
|
|
c4def9305f | ||
|
|
e456fed9d3 | ||
|
|
b95cd29508 | ||
|
|
11cbc32840 | ||
|
|
01539fb4d7 | ||
|
|
e47ee89dcf | ||
|
|
2f5c32c0b1 | ||
|
|
fbfe9f34bb | ||
|
|
d55e059fda | ||
|
|
9c572ab778 | ||
|
|
91403eeb63 | ||
|
|
3411d53856 | ||
|
|
efa2b38d07 | ||
|
|
d00605c5fb | ||
|
|
4bdeea4c63 | ||
|
|
f632f5d798 | ||
|
|
7d6d489f83 | ||
|
|
e8b1be1f22 | ||
|
|
d987955893 | ||
|
|
7b5435ac0b | ||
|
|
f289497e35 | ||
|
|
0d266984e6 | ||
|
|
cf92d4f8f2 | ||
|
|
b7cfc364ac | ||
|
|
de385d04fc | ||
|
|
b9f78c8a35 | ||
|
|
97b9e7b0ae | ||
|
|
e4b25981bf | ||
|
|
1017410468 | ||
|
|
fa71bb8624 | ||
|
|
d99839ec2a | ||
|
|
70b6bfd687 | ||
|
|
541c08f112 | ||
|
|
420433ae6f | ||
|
|
a7d77e0cf7 | ||
|
|
7ec2acc87d | ||
|
|
eeb5d22fe5 | ||
|
|
a757e80242 | ||
|
|
84d1d476b1 | ||
|
|
a52a606fb5 | ||
|
|
6ba39bb720 | ||
|
|
dd4b5c287c | ||
|
|
aa534e5875 | ||
|
|
2be9640c88 | ||
|
|
56567ae21a | ||
|
|
429e258322 | ||
|
|
2f57100061 | ||
|
|
fc62d5781d | ||
|
|
d86c5e5939 | ||
|
|
8006dd1c93 | ||
|
|
1dcbb4c88a | ||
|
|
4f4ffde4aa | ||
|
|
6d7a0ed93b | ||
|
|
b909b0363f | ||
|
|
a6fa6893eb | ||
|
|
561586bd98 | ||
|
|
7ddb30aade | ||
|
|
024425d45a | ||
|
|
4bdaf48f9b | ||
|
|
e9546e87ff | ||
|
|
0b34d581f9 | ||
|
|
5c2ed5e90e | ||
|
|
e9443ccd4a | ||
|
|
1fe0fe65e8 | ||
|
|
6823bad998 | ||
|
|
398e6c3406 | ||
|
|
710cc27de7 | ||
|
|
08212a478d | ||
|
|
d718a8045f | ||
|
|
2c4678c8c8 | ||
|
|
2466a300f4 | ||
|
|
66fcf1e853 | ||
|
|
5ebd54198d | ||
|
|
3a2d812510 | ||
|
|
7811557088 | ||
|
|
575f1602a1 | ||
|
|
50dc9b506b | ||
|
|
ae70ecc2f3 | ||
|
|
42284f4e64 | ||
|
|
6b5aa16668 | ||
|
|
706806a207 | ||
|
|
8a7502d7fa | ||
|
|
abc65a4871 | ||
|
|
7d5af61468 | ||
|
|
2badcdc31f | ||
|
|
64b8263450 | ||
|
|
13b4593f37 | ||
|
|
7dc65da6b2 | ||
|
|
98e56b773c | ||
|
|
7ff9778ff0 | ||
|
|
4af7bbf4ba | ||
|
|
f00a94d9e5 | ||
|
|
187ae44fa7 | ||
|
|
034178bf7d | ||
|
|
4064c78bc7 | ||
|
|
943b023b7c | ||
|
|
e3d654fd26 | ||
|
|
71d0470be1 | ||
|
|
53bbdc738f | ||
|
|
97707ec08e | ||
|
|
b9ce2f10c3 | ||
|
|
7cb3b13a33 | ||
|
|
e3d2b14911 | ||
|
|
58c9dc2a7e | ||
|
|
3bdf60340d | ||
|
|
c1e29824cd | ||
|
|
62f6df75f2 | ||
|
|
62bae86e86 | ||
|
|
aa69fbf85a | ||
|
|
8d41295f2c | ||
|
|
2b053d916d | ||
|
|
0d1309f322 | ||
|
|
c26250dcfe | ||
|
|
07c5c36be8 | ||
|
|
21c9cc5246 | ||
|
|
058960046a | ||
|
|
be80c18ea9 | ||
|
|
3c59a0cd95 | ||
|
|
26d0228ee9 | ||
|
|
9050646893 | ||
|
|
3ca09b9647 | ||
|
|
720ccca653 | ||
|
|
1e3dff8daa | ||
|
|
c116b325ab | ||
|
|
5b266d3fc9 | ||
|
|
6095e6272d | ||
|
|
f3fc5a62f2 | ||
|
|
ef7507cc9a | ||
|
|
16b7bea50d | ||
|
|
ccc4caad9c | ||
|
|
ba2c4fc586 | ||
|
|
bb5afb2df1 | ||
|
|
53f45f5f6f | ||
|
|
990040691c | ||
|
|
83857679cb | ||
|
|
61989da8ec | ||
|
|
768d8a808f | ||
|
|
95479a06bb | ||
|
|
4289d5fe02 | ||
|
|
5a6702845d | ||
|
|
ebf2192d98 | ||
|
|
44c09a19b0 | ||
|
|
4101ec98af | ||
|
|
a7c3300d7a | ||
|
|
b50acc9d21 | ||
|
|
fc76a9ada2 | ||
|
|
d6b4bf8ddc | ||
|
|
2c334c3c2d | ||
|
|
d3de6aa760 | ||
|
|
23b2ac4dcf | ||
|
|
e56c476105 | ||
|
|
14bb486174 | ||
|
|
12b49c986f | ||
|
|
64c8cd99ee | ||
|
|
7d718ddaa9 | ||
|
|
5570b3e24a | ||
|
|
945298ed2d | ||
|
|
f9b216af77 | ||
|
|
6525227036 | ||
|
|
214b1b8479 | ||
|
|
8bd161d453 | ||
|
|
64b88b6cdb | ||
|
|
0c25e712e1 | ||
|
|
6a070bf8c5 | ||
|
|
124495f0df | ||
|
|
43f64065b7 | ||
|
|
4f421aba65 | ||
|
|
8bec9c1234 | ||
|
|
ba6ac6ec63 | ||
|
|
b75796ed76 | ||
|
|
d82b4a7d98 | ||
|
|
5b79499d23 | ||
|
|
d78ff8b858 | ||
|
|
ef78384bfd | ||
|
|
d3ab7fb00b | ||
|
|
bebc4356af | ||
|
|
14bc966fee | ||
|
|
6a4b27b80d | ||
|
|
c5b4080649 | ||
|
|
408b25c82a | ||
|
|
228b0e3ecd | ||
|
|
f900bd57d0 | ||
|
|
6b190c6a18 | ||
|
|
c43bc4f5d6 | ||
|
|
9cd14a684f | ||
|
|
fc1675e54d | ||
|
|
a5abe1aa0f | ||
|
|
031998436f | ||
|
|
29cb65c26b | ||
|
|
179c0c0b23 | ||
|
|
03430e03bf | ||
|
|
169682d87a | ||
|
|
336eee688e | ||
|
|
32e4827559 | ||
|
|
7a81328b23 | ||
|
|
5b40b9de5a | ||
|
|
e327cef2c1 | ||
|
|
563d572ba0 | ||
|
|
687f09817b | ||
|
|
31dbc6fc91 | ||
|
|
8db2be8b09 | ||
|
|
a8bd00466a | ||
|
|
e78bb7b4f3 | ||
|
|
acaa0953df | ||
|
|
632e2c012e | ||
|
|
78f6a8b0f0 | ||
|
|
a9f997d00a | ||
|
|
dbe1fa76b3 | ||
|
|
74c4c7508b | ||
|
|
4809da6f9c | ||
|
|
7ffefce9e0 | ||
|
|
6cad522930 | ||
|
|
d683b05d7f | ||
|
|
b57e192965 |
@@ -9,5 +9,6 @@
|
|||||||
"WebFetch(domain:github.com)"
|
"WebFetch(domain:github.com)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
},
|
||||||
|
"outputStyle": "Explanatory"
|
||||||
}
|
}
|
||||||
|
|||||||
41
.cursor-plugin/plugin.json
Normal file
41
.cursor-plugin/plugin.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "shadcn",
|
||||||
|
"displayName": "shadcn/ui",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "UI component and design system framework. Search registries, install components as source code, and audit your project.",
|
||||||
|
"author": {
|
||||||
|
"name": "shadcn"
|
||||||
|
},
|
||||||
|
"homepage": "https://ui.shadcn.com",
|
||||||
|
"repository": "https://github.com/shadcn-ui/ui",
|
||||||
|
"license": "MIT",
|
||||||
|
"logo": "skills/shadcn/assets/shadcn.png",
|
||||||
|
"keywords": [
|
||||||
|
"shadcn",
|
||||||
|
"shadcn-ui",
|
||||||
|
"ui",
|
||||||
|
"components",
|
||||||
|
"tailwind",
|
||||||
|
"tailwindcss",
|
||||||
|
"radix",
|
||||||
|
"react",
|
||||||
|
"design-system",
|
||||||
|
"registry",
|
||||||
|
"mcp"
|
||||||
|
],
|
||||||
|
"category": "developer-tools",
|
||||||
|
"tags": [
|
||||||
|
"ui",
|
||||||
|
"components",
|
||||||
|
"design-system",
|
||||||
|
"react",
|
||||||
|
"tailwind"
|
||||||
|
],
|
||||||
|
"skills": "./skills/",
|
||||||
|
"mcpServers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["shadcn@latest", "mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
.cursor/rules/registry-bases-parity.mdc
Normal file
22
.cursor/rules/registry-bases-parity.mdc
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Keep registry base and radix trees in sync when editing shared UI
|
||||||
|
globs: apps/v4/registry/bases/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Registry bases: Base UI ↔ Radix parity
|
||||||
|
|
||||||
|
`apps/v4/registry/bases/base` and `apps/v4/registry/bases/radix` are **parallel registries**. Anything that exists in both trees for the same purpose (preview blocks, mirrored examples, shared card layouts, etc.) **must stay in sync**.
|
||||||
|
|
||||||
|
## When editing
|
||||||
|
|
||||||
|
- If you change a file under **`bases/base/...`**, apply the **same behavioral and visual change** to the matching path under **`bases/radix/...`** (and the reverse).
|
||||||
|
- Only diverge where APIs differ (e.g. import paths like `@/registry/bases/base/ui/*` vs `@/registry/bases/radix/ui/*`, or Base UI vs Radix component props).
|
||||||
|
- Do **not** update only one side unless the user explicitly asks for a single-base change.
|
||||||
|
|
||||||
|
## Typical mirrored paths
|
||||||
|
|
||||||
|
- `blocks/preview/**` — preview cards and blocks
|
||||||
|
- Parallel `ui/*` components when both exist for the same component
|
||||||
|
|
||||||
|
After edits, briefly confirm both trees were updated (or state why one side is intentionally unchanged).
|
||||||
9
.github/workflows/prerelease-comment.yml
vendored
9
.github/workflows/prerelease-comment.yml
vendored
@@ -3,7 +3,7 @@ name: Write Beta Release comment
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Release - Beta"]
|
workflows: ["Release"]
|
||||||
types:
|
types:
|
||||||
- completed
|
- completed
|
||||||
|
|
||||||
@@ -11,12 +11,13 @@ jobs:
|
|||||||
comment:
|
comment:
|
||||||
if: |
|
if: |
|
||||||
github.repository_owner == 'shadcn-ui' &&
|
github.repository_owner == 'shadcn-ui' &&
|
||||||
${{ github.event.workflow_run.conclusion == 'success' }}
|
github.event.workflow_run.event == 'pull_request' &&
|
||||||
|
github.event.workflow_run.conclusion == 'success'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Write comment to the PR
|
name: Write comment to the PR
|
||||||
steps:
|
steps:
|
||||||
- name: "Comment on PR"
|
- name: "Comment on PR"
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
@@ -53,7 +54,7 @@ jobs:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- name: "Remove the autorelease label once published"
|
- name: "Remove the autorelease label once published"
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
64
.github/workflows/prerelease.yml
vendored
64
.github/workflows/prerelease.yml
vendored
@@ -1,64 +0,0 @@
|
|||||||
# Adapted from create-t3-app.
|
|
||||||
|
|
||||||
name: Release - Beta
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [labeled]
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prerelease:
|
|
||||||
if: |
|
|
||||||
github.repository_owner == 'shadcn-ui' &&
|
|
||||||
contains(github.event.pull_request.labels.*.name, '🚀 autorelease')
|
|
||||||
name: Build & Publish a beta release to NPM
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: Preview
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Use PNPM
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9.0.6
|
|
||||||
|
|
||||||
- name: Use Node.js 20
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
registry-url: "https://registry.npmjs.org"
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: Update npm for OIDC support
|
|
||||||
run: npm install -g npm@latest
|
|
||||||
|
|
||||||
- name: Install NPM Dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Modify package.json version
|
|
||||||
run: node .github/version-script-beta.js
|
|
||||||
|
|
||||||
- name: Publish Beta to NPM
|
|
||||||
run: pnpm pub:beta
|
|
||||||
|
|
||||||
- name: get-npm-version
|
|
||||||
id: package-version
|
|
||||||
uses: martinbeentjes/npm-get-version-action@main
|
|
||||||
with:
|
|
||||||
path: packages/shadcn
|
|
||||||
|
|
||||||
- name: Upload packaged artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
|
|
||||||
path: packages/shadcn/dist/index.js
|
|
||||||
84
.github/workflows/release.yml
vendored
84
.github/workflows/release.yml
vendored
@@ -2,24 +2,81 @@
|
|||||||
|
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
|
run-name: ${{ github.event_name == 'pull_request' && format('Release Beta - PR {0}', github.event.number) || 'Release Stable' }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [labeled]
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
prerelease:
|
||||||
if: ${{ github.repository_owner == 'shadcn-ui' }}
|
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && contains(github.event.pull_request.labels.*.name, '🚀 autorelease') }}
|
||||||
name: Create a PR for release workflow
|
name: Publish Beta to NPM
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: Preview
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Use PNPM
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9.0.6
|
||||||
|
|
||||||
|
- name: Use Node.js 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Update npm for OIDC support
|
||||||
|
run: npm install -g npm@latest
|
||||||
|
|
||||||
|
- name: Install NPM Dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Modify package.json version
|
||||||
|
run: node .github/version-script-beta.js
|
||||||
|
|
||||||
|
- name: Publish Beta to NPM
|
||||||
|
run: pnpm pub:beta
|
||||||
|
|
||||||
|
- name: get-npm-version
|
||||||
|
id: package-version
|
||||||
|
uses: martinbeentjes/npm-get-version-action@main
|
||||||
|
with:
|
||||||
|
path: packages/shadcn
|
||||||
|
|
||||||
|
- name: Upload packaged artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
|
||||||
|
path: packages/shadcn/dist/index.js
|
||||||
|
|
||||||
|
release:
|
||||||
|
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
|
||||||
|
name: Create Version PR or Publish Stable Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -47,10 +104,19 @@ jobs:
|
|||||||
- name: Build the package
|
- name: Build the package
|
||||||
run: pnpm shadcn:build
|
run: pnpm shadcn:build
|
||||||
|
|
||||||
|
- name: Import GPG key
|
||||||
|
uses: crazy-max/ghaction-import-gpg@v6
|
||||||
|
with:
|
||||||
|
gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||||
|
git_user_signingkey: true
|
||||||
|
git_commit_gpgsign: true
|
||||||
|
git_tag_gpgsign: true
|
||||||
|
|
||||||
- name: Create Version PR or Publish to NPM
|
- name: Create Version PR or Publish to NPM
|
||||||
id: changesets
|
id: changesets
|
||||||
uses: changesets/action@v1
|
uses: changesets/action@v1
|
||||||
with:
|
with:
|
||||||
|
setupGitUser: false
|
||||||
commit: "chore(release): version packages"
|
commit: "chore(release): version packages"
|
||||||
title: "chore(release): version packages"
|
title: "chore(release): version packages"
|
||||||
version: node .github/changeset-version.js
|
version: node .github/changeset-version.js
|
||||||
|
|||||||
75
.github/workflows/signed-commits.yml
vendored
Normal file
75
.github/workflows/signed-commits.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
name: Signed commits
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
signed-commits:
|
||||||
|
if: github.repository_owner == 'shadcn-ui'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Signed commits
|
||||||
|
steps:
|
||||||
|
- name: Check PR commits
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const body = "Can you sign the commits please? See https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits. Thank you."
|
||||||
|
|
||||||
|
const { owner, repo } = context.repo
|
||||||
|
const pullNumber = context.payload.pull_request.number
|
||||||
|
|
||||||
|
const commits = await github.paginate(github.rest.pulls.listCommits, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: pullNumber,
|
||||||
|
per_page: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsignedCommits = commits.filter((commit) => {
|
||||||
|
return commit.commit.verification?.reason === "unsigned"
|
||||||
|
})
|
||||||
|
|
||||||
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: pullNumber,
|
||||||
|
per_page: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingComments = comments.filter((comment) => {
|
||||||
|
return comment.user.type === "Bot" && comment.body.trim() === body
|
||||||
|
})
|
||||||
|
|
||||||
|
if (unsignedCommits.length > 0) {
|
||||||
|
core.info(`Found ${unsignedCommits.length} unsigned commits.`)
|
||||||
|
|
||||||
|
if (existingComments.length === 0) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: pullNumber,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info("All commits are signed.")
|
||||||
|
|
||||||
|
for (const comment of existingComments) {
|
||||||
|
await github.rest.issues.deleteComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
comment_id: comment.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -39,6 +39,9 @@ jobs:
|
|||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
|
- name: Install Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,6 +15,7 @@ build
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.eslintcache
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
@@ -43,3 +44,4 @@ tsconfig.tsbuildinfo
|
|||||||
.notes
|
.notes
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
shadcn-workspace
|
shadcn-workspace
|
||||||
|
.codex-artifacts
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Button } from "@/examples/radix/ui/button"
|
import { IconMinus, IconPlus } from "@tabler/icons-react"
|
||||||
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
|
||||||
|
import { Button } from "@/styles/radix-nova/ui/button"
|
||||||
|
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldContent,
|
FieldContent,
|
||||||
@@ -13,11 +15,10 @@ import {
|
|||||||
FieldSeparator,
|
FieldSeparator,
|
||||||
FieldSet,
|
FieldSet,
|
||||||
FieldTitle,
|
FieldTitle,
|
||||||
} from "@/examples/radix/ui/field"
|
} from "@/styles/radix-nova/ui/field"
|
||||||
import { Input } from "@/examples/radix/ui/input"
|
import { Input } from "@/styles/radix-nova/ui/input"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/examples/radix/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/styles/radix-nova/ui/radio-group"
|
||||||
import { Switch } from "@/examples/radix/ui/switch"
|
import { Switch } from "@/styles/radix-nova/ui/switch"
|
||||||
import { IconMinus, IconPlus } from "@tabler/icons-react"
|
|
||||||
|
|
||||||
export function AppearanceSettings() {
|
export function AppearanceSettings() {
|
||||||
const [gpuCount, setGpuCount] = React.useState(8)
|
const [gpuCount, setGpuCount] = React.useState(8)
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Button } from "@/examples/radix/ui/button"
|
import {
|
||||||
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
ArchiveIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
CalendarPlusIcon,
|
||||||
|
ClockIcon,
|
||||||
|
ListFilterIcon,
|
||||||
|
MailCheckIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
TagIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/styles/radix-nova/ui/button"
|
||||||
|
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -15,18 +27,7 @@ import {
|
|||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/examples/radix/ui/dropdown-menu"
|
} from "@/styles/radix-nova/ui/dropdown-menu"
|
||||||
import {
|
|
||||||
ArchiveIcon,
|
|
||||||
ArrowLeftIcon,
|
|
||||||
CalendarPlusIcon,
|
|
||||||
ClockIcon,
|
|
||||||
ListFilterIcon,
|
|
||||||
MailCheckIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
TagIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
export function ButtonGroupDemo() {
|
export function ButtonGroupDemo() {
|
||||||
const [label, setLabel] = React.useState("personal")
|
const [label, setLabel] = React.useState("personal")
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Button } from "@/examples/radix/ui/button"
|
import { AudioLinesIcon, PlusIcon } from "lucide-react"
|
||||||
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
|
||||||
|
import { Button } from "@/styles/radix-nova/ui/button"
|
||||||
|
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
|
||||||
import {
|
import {
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputGroupAddon,
|
InputGroupAddon,
|
||||||
InputGroupButton,
|
InputGroupButton,
|
||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
} from "@/examples/radix/ui/input-group"
|
} from "@/styles/radix-nova/ui/input-group"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/examples/radix/ui/tooltip"
|
} from "@/styles/radix-nova/ui/tooltip"
|
||||||
import { AudioLinesIcon, PlusIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function ButtonGroupInputGroup() {
|
export function ButtonGroupInputGroup() {
|
||||||
const [voiceEnabled, setVoiceEnabled] = React.useState(false)
|
const [voiceEnabled, setVoiceEnabled] = React.useState(false)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/examples/radix/ui/button"
|
|
||||||
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
|
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/styles/radix-nova/ui/button"
|
||||||
|
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
|
||||||
|
|
||||||
export function ButtonGroupNested() {
|
export function ButtonGroupNested() {
|
||||||
return (
|
return (
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Button } from "@/examples/radix/ui/button"
|
import { BotIcon, ChevronDownIcon } from "lucide-react"
|
||||||
import { ButtonGroup } from "@/examples/radix/ui/button-group"
|
|
||||||
|
import { Button } from "@/styles/radix-nova/ui/button"
|
||||||
|
import { ButtonGroup } from "@/styles/radix-nova/ui/button-group"
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/examples/radix/ui/popover"
|
} from "@/styles/radix-nova/ui/popover"
|
||||||
import { Separator } from "@/examples/radix/ui/separator"
|
import { Separator } from "@/styles/radix-nova/ui/separator"
|
||||||
import { Textarea } from "@/examples/radix/ui/textarea"
|
import { Textarea } from "@/styles/radix-nova/ui/textarea"
|
||||||
import { BotIcon, ChevronDownIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function ButtonGroupPopover() {
|
export function ButtonGroupPopover() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
AvatarGroup,
|
AvatarGroup,
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
} from "@/examples/radix/ui/avatar"
|
} from "@/styles/radix-nova/ui/avatar"
|
||||||
import { Button } from "@/examples/radix/ui/button"
|
import { Button } from "@/styles/radix-nova/ui/button"
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
@@ -12,8 +14,7 @@ import {
|
|||||||
EmptyHeader,
|
EmptyHeader,
|
||||||
EmptyMedia,
|
EmptyMedia,
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/examples/radix/ui/empty"
|
} from "@/styles/radix-nova/ui/empty"
|
||||||
import { PlusIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function EmptyAvatarGroup() {
|
export function EmptyAvatarGroup() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Checkbox } from "@/examples/radix/ui/checkbox"
|
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
|
||||||
import { Field, FieldLabel } from "@/examples/radix/ui/field"
|
import { Field, FieldLabel } from "@/styles/radix-nova/ui/field"
|
||||||
|
|
||||||
export function FieldCheckbox() {
|
export function FieldCheckbox() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button } from "@/examples/radix/ui/button"
|
import { Button } from "@/styles/radix-nova/ui/button"
|
||||||
import { Checkbox } from "@/examples/radix/ui/checkbox"
|
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
FieldLegend,
|
FieldLegend,
|
||||||
FieldSeparator,
|
FieldSeparator,
|
||||||
FieldSet,
|
FieldSet,
|
||||||
} from "@/examples/radix/ui/field"
|
} from "@/styles/radix-nova/ui/field"
|
||||||
import { Input } from "@/examples/radix/ui/input"
|
import { Input } from "@/styles/radix-nova/ui/input"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/examples/radix/ui/select"
|
} from "@/styles/radix-nova/ui/select"
|
||||||
import { Textarea } from "@/examples/radix/ui/textarea"
|
import { Textarea } from "@/styles/radix-nova/ui/textarea"
|
||||||
|
|
||||||
export function FieldDemo() {
|
export function FieldDemo() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Card, CardContent } from "@/examples/radix/ui/card"
|
import { Card, CardContent } from "@/styles/radix-nova/ui/card"
|
||||||
import { Checkbox } from "@/examples/radix/ui/checkbox"
|
import { Checkbox } from "@/styles/radix-nova/ui/checkbox"
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
FieldLegend,
|
FieldLegend,
|
||||||
FieldSet,
|
FieldSet,
|
||||||
FieldTitle,
|
FieldTitle,
|
||||||
} from "@/examples/radix/ui/field"
|
} from "@/styles/radix-nova/ui/field"
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Field, FieldDescription, FieldTitle } from "@/examples/radix/ui/field"
|
|
||||||
import { Slider } from "@/examples/radix/ui/slider"
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@/styles/radix-nova/ui/field"
|
||||||
|
import { Slider } from "@/styles/radix-nova/ui/slider"
|
||||||
|
|
||||||
export function FieldSlider() {
|
export function FieldSlider() {
|
||||||
const [value, setValue] = useState([200, 800])
|
const [value, setValue] = useState([200, 800])
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FieldSeparator } from "@/examples/radix/ui/field"
|
import { FieldSeparator } from "@/styles/radix-nova/ui/field"
|
||||||
|
|
||||||
import { AppearanceSettings } from "./appearance-settings"
|
import { AppearanceSettings } from "./appearance-settings"
|
||||||
import { ButtonGroupDemo } from "./button-group-demo"
|
import { ButtonGroupDemo } from "./button-group-demo"
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputGroupAddon,
|
InputGroupAddon,
|
||||||
InputGroupButton,
|
InputGroupButton,
|
||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
} from "@/examples/radix/ui/input-group"
|
} from "@/styles/radix-nova/ui/input-group"
|
||||||
import { Label } from "@/examples/radix/ui/label"
|
import { Label } from "@/styles/radix-nova/ui/label"
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/examples/radix/ui/popover"
|
} from "@/styles/radix-nova/ui/popover"
|
||||||
import { IconInfoCircle, IconStar } from "@tabler/icons-react"
|
|
||||||
|
|
||||||
export function InputGroupButtonExample() {
|
export function InputGroupButtonExample() {
|
||||||
const [isFavorite, setIsFavorite] = React.useState(false)
|
const [isFavorite, setIsFavorite] = React.useState(false)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { IconCheck, IconInfoCircle, IconPlus } from "@tabler/icons-react"
|
||||||
|
import { ArrowUpIcon, Search } from "lucide-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/examples/radix/ui/dropdown-menu"
|
} from "@/styles/radix-nova/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputGroupAddon,
|
InputGroupAddon,
|
||||||
@@ -11,15 +14,13 @@ import {
|
|||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
InputGroupText,
|
InputGroupText,
|
||||||
InputGroupTextarea,
|
InputGroupTextarea,
|
||||||
} from "@/examples/radix/ui/input-group"
|
} from "@/styles/radix-nova/ui/input-group"
|
||||||
import { Separator } from "@/examples/radix/ui/separator"
|
import { Separator } from "@/styles/radix-nova/ui/separator"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/examples/radix/ui/tooltip"
|
} from "@/styles/radix-nova/ui/tooltip"
|
||||||
import { IconCheck, IconInfoCircle, IconPlus } from "@tabler/icons-react"
|
|
||||||
import { ArrowUpIcon, Search } from "lucide-react"
|
|
||||||
|
|
||||||
export function InputGroupDemo() {
|
export function InputGroupDemo() {
|
||||||
return (
|
return (
|
||||||
@@ -88,7 +89,7 @@ export function InputGroupDemo() {
|
|||||||
<InputGroupInput placeholder="@shadcn" />
|
<InputGroupInput placeholder="@shadcn" />
|
||||||
<InputGroupAddon align="inline-end">
|
<InputGroupAddon align="inline-end">
|
||||||
<div className="flex size-4 items-center justify-center rounded-full bg-primary text-foreground">
|
<div className="flex size-4 items-center justify-center rounded-full bg-primary text-foreground">
|
||||||
<IconCheck className="size-3 text-white" />
|
<IconCheck className="size-3 text-background" />
|
||||||
</div>
|
</div>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Button } from "@/examples/radix/ui/button"
|
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/styles/radix-nova/ui/button"
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
ItemActions,
|
ItemActions,
|
||||||
@@ -6,8 +8,7 @@ import {
|
|||||||
ItemDescription,
|
ItemDescription,
|
||||||
ItemMedia,
|
ItemMedia,
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "@/examples/radix/ui/item"
|
} from "@/styles/radix-nova/ui/item"
|
||||||
import { BadgeCheckIcon, ChevronRightIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function ItemDemo() {
|
export function ItemDemo() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/examples/radix/ui/avatar"
|
import {
|
||||||
import { Badge } from "@/examples/radix/ui/badge"
|
IconApps,
|
||||||
|
IconArrowUp,
|
||||||
|
IconAt,
|
||||||
|
IconBook,
|
||||||
|
IconCircleDashedPlus,
|
||||||
|
IconPaperclip,
|
||||||
|
IconPlus,
|
||||||
|
IconWorld,
|
||||||
|
IconX,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/styles/radix-nova/ui/avatar"
|
||||||
|
import { Badge } from "@/styles/radix-nova/ui/badge"
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -10,7 +26,7 @@ import {
|
|||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@/examples/radix/ui/command"
|
} from "@/styles/radix-nova/ui/command"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -23,36 +39,25 @@ import {
|
|||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/examples/radix/ui/dropdown-menu"
|
} from "@/styles/radix-nova/ui/dropdown-menu"
|
||||||
import { Field, FieldLabel } from "@/examples/radix/ui/field"
|
import { Field, FieldLabel } from "@/styles/radix-nova/ui/field"
|
||||||
import {
|
import {
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputGroupAddon,
|
InputGroupAddon,
|
||||||
InputGroupButton,
|
InputGroupButton,
|
||||||
InputGroupTextarea,
|
InputGroupTextarea,
|
||||||
} from "@/examples/radix/ui/input-group"
|
} from "@/styles/radix-nova/ui/input-group"
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/examples/radix/ui/popover"
|
} from "@/styles/radix-nova/ui/popover"
|
||||||
import { Switch } from "@/examples/radix/ui/switch"
|
import { Switch } from "@/styles/radix-nova/ui/switch"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/examples/radix/ui/tooltip"
|
} from "@/styles/radix-nova/ui/tooltip"
|
||||||
import {
|
|
||||||
IconApps,
|
|
||||||
IconArrowUp,
|
|
||||||
IconAt,
|
|
||||||
IconBook,
|
|
||||||
IconCircleDashedPlus,
|
|
||||||
IconPaperclip,
|
|
||||||
IconPlus,
|
|
||||||
IconWorld,
|
|
||||||
IconX,
|
|
||||||
} from "@tabler/icons-react"
|
|
||||||
|
|
||||||
const SAMPLE_DATA = {
|
const SAMPLE_DATA = {
|
||||||
mentionable: [
|
mentionable: [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge } from "@/examples/radix/ui/badge"
|
import { Badge } from "@/styles/radix-nova/ui/badge"
|
||||||
import { Spinner } from "@/examples/radix/ui/spinner"
|
import { Spinner } from "@/styles/radix-nova/ui/spinner"
|
||||||
|
|
||||||
export function SpinnerBadge() {
|
export function SpinnerBadge() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button } from "@/examples/radix/ui/button"
|
import { Button } from "@/styles/radix-nova/ui/button"
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
EmptyHeader,
|
EmptyHeader,
|
||||||
EmptyMedia,
|
EmptyMedia,
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/examples/radix/ui/empty"
|
} from "@/styles/radix-nova/ui/empty"
|
||||||
import { Spinner } from "@/examples/radix/ui/spinner"
|
import { Spinner } from "@/styles/radix-nova/ui/spinner"
|
||||||
|
|
||||||
export function SpinnerEmpty() {
|
export function SpinnerEmpty() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ import Image from "next/image"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
import { Announcement } from "@/components/announcement"
|
import { Announcement } from "@/components/announcement"
|
||||||
import { ExamplesNav } from "@/components/examples-nav"
|
|
||||||
import {
|
import {
|
||||||
PageActions,
|
PageActions,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageHeaderDescription,
|
PageHeaderDescription,
|
||||||
PageHeaderHeading,
|
PageHeaderHeading,
|
||||||
} from "@/components/page-header"
|
} from "@/components/page-header"
|
||||||
import { PageNav } from "@/components/page-nav"
|
|
||||||
import { ThemeSelector } from "@/components/theme-selector"
|
|
||||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||||
|
|
||||||
import { RootComponents } from "./components"
|
import { RootComponents } from "./components"
|
||||||
@@ -63,11 +60,7 @@ export default function IndexPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</PageActions>
|
</PageActions>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageNav className="hidden md:flex">
|
<div className="container-wrapper flex-1 pb-6">
|
||||||
<ExamplesNav className="flex-1 overflow-hidden [&>a:first-child]:text-primary" />
|
|
||||||
<ThemeSelector className="mr-4 hidden md:flex" />
|
|
||||||
</PageNav>
|
|
||||||
<div className="container-wrapper flex-1 section-soft pb-6">
|
|
||||||
<div className="container overflow-hidden">
|
<div className="container overflow-hidden">
|
||||||
<section className="-mx-4 w-[160vw] overflow-hidden rounded-lg border border-border/50 md:hidden md:w-[150vw]">
|
<section className="-mx-4 w-[160vw] overflow-hidden rounded-lg border border-border/50 md:hidden md:w-[150vw]">
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Badge } from "@/styles/base-sera/ui/badge"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
} from "@/styles/base-sera/ui/card"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@/styles/base-sera/ui/input-group"
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
} from "@/styles/base-sera/ui/pagination"
|
||||||
|
import { Progress, ProgressValue } from "@/styles/base-sera/ui/progress"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/styles/base-sera/ui/table"
|
||||||
|
|
||||||
|
const ARTICLE_ROWS = [
|
||||||
|
{
|
||||||
|
title: "The Future of Sustainable Architecture",
|
||||||
|
wordProgress: "1.4k / 2.6k words",
|
||||||
|
author: "Elena Rostova",
|
||||||
|
issue: "Summer 2024",
|
||||||
|
status: "in-revision",
|
||||||
|
statusLabel: "In revision",
|
||||||
|
progress: 45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Brutalism's Second Act",
|
||||||
|
wordProgress: "2.1k / 2.5k words",
|
||||||
|
author: "Marcus Chen",
|
||||||
|
issue: "Summer 2024",
|
||||||
|
status: "final-edit",
|
||||||
|
statusLabel: "Final edit",
|
||||||
|
progress: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "The Typography of Public Spaces",
|
||||||
|
wordProgress: "0.5k / 1.5k words",
|
||||||
|
author: "Sarah Jenkins",
|
||||||
|
issue: "Autumn 2024",
|
||||||
|
status: "drafting",
|
||||||
|
statusLabel: "Drafting",
|
||||||
|
progress: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Rethinking Urban Canopies",
|
||||||
|
wordProgress: "1.8k / 1.8k words",
|
||||||
|
author: "David O'Connor",
|
||||||
|
issue: "Summer 2024",
|
||||||
|
status: "published",
|
||||||
|
statusLabel: "Published",
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Light, Glass, and the Modern Museum",
|
||||||
|
wordProgress: "1.2k / 2.0k words",
|
||||||
|
author: "Amara Osei",
|
||||||
|
issue: "Autumn 2024",
|
||||||
|
status: "in-revision",
|
||||||
|
statusLabel: "In revision",
|
||||||
|
progress: 55,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Concrete Utopias: Housing in the 21st Century",
|
||||||
|
wordProgress: "3.0k / 3.0k words",
|
||||||
|
author: "Tomás Herrera",
|
||||||
|
issue: "Summer 2024",
|
||||||
|
status: "published",
|
||||||
|
statusLabel: "Published",
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Designing for Silence",
|
||||||
|
wordProgress: "0.8k / 2.2k words",
|
||||||
|
author: "Ingrid Solberg",
|
||||||
|
issue: "Winter 2024",
|
||||||
|
status: "drafting",
|
||||||
|
statusLabel: "Drafting",
|
||||||
|
progress: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "The Invisible Infrastructure of Cities",
|
||||||
|
wordProgress: "2.4k / 2.8k words",
|
||||||
|
author: "James Whitfield",
|
||||||
|
issue: "Autumn 2024",
|
||||||
|
status: "final-edit",
|
||||||
|
statusLabel: "Final edit",
|
||||||
|
progress: 85,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const STATUS_BADGE_VARIANT = {
|
||||||
|
"in-revision": "outline",
|
||||||
|
"final-edit": "default",
|
||||||
|
drafting: "ghost",
|
||||||
|
published: "secondary",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const STATUS_DOT_CLASSNAME = {
|
||||||
|
"in-revision": "bg-amber-600/80",
|
||||||
|
"final-edit": "bg-foreground/90",
|
||||||
|
drafting: "bg-muted-foreground/60",
|
||||||
|
published: "bg-emerald-600/80",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleDirectory() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput type="search" placeholder="Search articles..." />
|
||||||
|
</InputGroup>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead className="w-[170px]">Author</TableHead>
|
||||||
|
<TableHead className="w-[150px]">Issue</TableHead>
|
||||||
|
<TableHead className="w-[180px]">Status</TableHead>
|
||||||
|
<TableHead className="w-[140px]">Progress</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{ARTICLE_ROWS.map((row) => (
|
||||||
|
<TableRow key={row.title}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-heading text-xl tracking-tight text-foreground">
|
||||||
|
{row.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{row.wordProgress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.author}</TableCell>
|
||||||
|
<TableCell>{row.issue}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={STATUS_BADGE_VARIANT[row.status]}>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
STATUS_DOT_CLASSNAME[row.status]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{row.statusLabel}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Progress
|
||||||
|
value={row.progress}
|
||||||
|
aria-label={`${row.progress}% complete`}
|
||||||
|
className="flex flex-row-reverse items-center **:data-[slot=progress-track]:w-16"
|
||||||
|
>
|
||||||
|
<ProgressValue>
|
||||||
|
{(formattedValue) => `${formattedValue}`}
|
||||||
|
</ProgressValue>
|
||||||
|
</Progress>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="cn-rtl-flip" />
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
{[1, 2, 3].map((page) => (
|
||||||
|
<PaginationItem key={page}>
|
||||||
|
<PaginationLink href="#" size="icon-sm" isActive={page === 1}>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href="#" size="icon-sm" aria-label="Next page">
|
||||||
|
<ChevronRightIcon className="cn-rtl-flip" />
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ArrowLeftIcon, PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/styles/base-sera/ui/breadcrumb"
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
|
||||||
|
|
||||||
|
export function PreviewHeader() {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-2 text-center sm:text-left">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList className="justify-center md:justify-start">
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink
|
||||||
|
href="#"
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-3" />
|
||||||
|
Editorial Dashboard
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
|
||||||
|
Article Directory
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
|
||||||
|
<Button>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
New Article
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
apps/v4/app/(app)/(styles)/sera/article-directory/index.tsx
Normal file
16
apps/v4/app/(app)/(styles)/sera/article-directory/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Separator } from "@/styles/base-sera/ui/separator"
|
||||||
|
|
||||||
|
import { ArticleDirectory as ArticleDirectoryList } from "./components/article-directory"
|
||||||
|
import { PreviewHeader } from "./components/preview-header"
|
||||||
|
|
||||||
|
export function ArticleDirectory() {
|
||||||
|
return (
|
||||||
|
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
|
||||||
|
<PreviewHeader />
|
||||||
|
<Separator className="hidden sm:block" />
|
||||||
|
<div className="container py-(--gap)">
|
||||||
|
<ArticleDirectoryList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { MoveRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/styles/base-sera/ui/card"
|
||||||
|
import {
|
||||||
|
Progress,
|
||||||
|
ProgressLabel,
|
||||||
|
ProgressValue,
|
||||||
|
} from "@/styles/base-sera/ui/progress"
|
||||||
|
|
||||||
|
const DEMOGRAPHIC_DATA = [
|
||||||
|
{ age: "18 - 24", percentage: 22 },
|
||||||
|
{ age: "25 - 34", percentage: 64 },
|
||||||
|
{ age: "35 - 44", percentage: 12 },
|
||||||
|
{ age: "45+", percentage: 5 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Demographics({ ...props }: React.ComponentProps<typeof Card>) {
|
||||||
|
return (
|
||||||
|
<Card {...props}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">Demographics</CardTitle>
|
||||||
|
<CardDescription>Reader Profile</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-10">
|
||||||
|
{DEMOGRAPHIC_DATA.map((item) => (
|
||||||
|
<Progress
|
||||||
|
key={item.age}
|
||||||
|
value={item.percentage}
|
||||||
|
aria-label={item.age}
|
||||||
|
>
|
||||||
|
<ProgressLabel>{item.age}</ProgressLabel>
|
||||||
|
<ProgressValue>
|
||||||
|
{(formattedValue) => `${formattedValue}`}
|
||||||
|
</ProgressValue>
|
||||||
|
</Progress>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="link" className="w-full">
|
||||||
|
View all source <MoveRightIcon data-icon="inline-end" />
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { TrendingDownIcon, TrendingUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/styles/base-sera/ui/card"
|
||||||
|
|
||||||
|
type Metric = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
comparison: string
|
||||||
|
change: string
|
||||||
|
trend: "up" | "down"
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRIC_CARDS: Metric[] = [
|
||||||
|
{
|
||||||
|
label: "Total visitors",
|
||||||
|
value: "248.5k",
|
||||||
|
comparison: "12.4%",
|
||||||
|
change: "vs last period",
|
||||||
|
trend: "up",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Unique readers",
|
||||||
|
value: "182.1k",
|
||||||
|
comparison: "8.7%",
|
||||||
|
change: "vs last period",
|
||||||
|
trend: "up",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg. time on page",
|
||||||
|
value: "3m 42s",
|
||||||
|
comparison: "1.2%",
|
||||||
|
change: "vs last period",
|
||||||
|
trend: "down",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Bounce rate",
|
||||||
|
value: "42.8%",
|
||||||
|
comparison: "3.5%",
|
||||||
|
change: "vs last period",
|
||||||
|
trend: "down",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function MetricsGrid() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{METRIC_CARDS.map((metric) => (
|
||||||
|
<MetricCard
|
||||||
|
key={metric.label}
|
||||||
|
metric={metric}
|
||||||
|
className="col-span-full md:col-span-6 lg:col-span-3"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
metric,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
metric: Metric
|
||||||
|
className: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className={cn("gap-0", className)}>
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
<CardDescription className="text-xs uppercase">
|
||||||
|
{metric.label}
|
||||||
|
</CardDescription>
|
||||||
|
<CardTitle className="text-5xl tracking-tight lowercase">
|
||||||
|
{metric.value}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{metric.trend === "up" ? (
|
||||||
|
<TrendingUpIcon className="inline-block size-2.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<TrendingDownIcon className="inline-block size-2.5 text-muted-foreground" />
|
||||||
|
)}{" "}
|
||||||
|
<span className="text-foreground">{metric.comparison}</span>{" "}
|
||||||
|
<span>{metric.change}</span>
|
||||||
|
</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ChevronDownIcon, DownloadIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/styles/base-sera/ui/dropdown-menu"
|
||||||
|
|
||||||
|
const EXPORT_DATE_OPTIONS = [
|
||||||
|
{
|
||||||
|
label: "Last 7 days",
|
||||||
|
value: "last-7-days",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 30 days",
|
||||||
|
value: "last-30-days",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "This month",
|
||||||
|
value: "this-month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last month",
|
||||||
|
value: "last-month",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PreviewHeader() {
|
||||||
|
const [selectedDateRange, setSelectedDateRange] =
|
||||||
|
React.useState("last-30-days")
|
||||||
|
|
||||||
|
const selectedDateRangeLabel = React.useMemo(() => {
|
||||||
|
const selectedOption = EXPORT_DATE_OPTIONS.find(
|
||||||
|
(option) => option.value === selectedDateRange
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!selectedOption) {
|
||||||
|
return "Last 30 days"
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedOption.label
|
||||||
|
}, [selectedDateRange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-2 text-center sm:text-left">
|
||||||
|
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
|
||||||
|
Audience Analytics
|
||||||
|
</h1>
|
||||||
|
<div className="line-clamp-1 text-sm font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
Editorial Performance Dashboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="bg-background hover:bg-background/80 data-popup-open:bg-background"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedDateRangeLabel}{" "}
|
||||||
|
<ChevronDownIcon data-icon="inline-end" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={selectedDateRange}
|
||||||
|
onValueChange={setSelectedDateRange}
|
||||||
|
>
|
||||||
|
{EXPORT_DATE_OPTIONS.map((option) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button>
|
||||||
|
<DownloadIcon data-icon="inline-start" />
|
||||||
|
<span className="lg:hidden">Export</span>
|
||||||
|
<span className="hidden lg:inline">Export Report</span>
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ArrowDownIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/styles/base-sera/ui/card"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/styles/base-sera/ui/dropdown-menu"
|
||||||
|
import { Spinner } from "@/styles/base-sera/ui/spinner"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/styles/base-sera/ui/table"
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/styles/base-sera/ui/toggle-group"
|
||||||
|
|
||||||
|
type EditorialMetric = "views" | "time" | "shares"
|
||||||
|
|
||||||
|
type EditorialRow = {
|
||||||
|
rank: number
|
||||||
|
title: string
|
||||||
|
author: string
|
||||||
|
published: string
|
||||||
|
pageviews: string
|
||||||
|
avgTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRIC_LABEL: Record<EditorialMetric, string> = {
|
||||||
|
views: "VIEWS",
|
||||||
|
time: "TIME",
|
||||||
|
shares: "SHARES",
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDITORIAL_ROWS: EditorialRow[] = [
|
||||||
|
{
|
||||||
|
rank: 1,
|
||||||
|
title: "The New Vanguard of Minimalist Architecture",
|
||||||
|
author: "Elena Rostova",
|
||||||
|
published: "Oct 12",
|
||||||
|
pageviews: "45.2k",
|
||||||
|
avgTime: "04:15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 2,
|
||||||
|
title: "Autumn Sartorial Code: Deconstructed Classics",
|
||||||
|
author: "Julian Vance",
|
||||||
|
published: "Oct 05",
|
||||||
|
pageviews: "38.9k",
|
||||||
|
avgTime: "03:42",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 3,
|
||||||
|
title: "Interview: Director Sofia Coppola on The Aesthetics of Isolation",
|
||||||
|
author: "Marcus Trent",
|
||||||
|
published: "Sep 28",
|
||||||
|
pageviews: "31.4k",
|
||||||
|
avgTime: "06:20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 4,
|
||||||
|
title: "Sourcing Ceramics from Kyoto's Oldest Kilns",
|
||||||
|
author: "Sarah Lin",
|
||||||
|
published: "Oct 18",
|
||||||
|
pageviews: "22.1k",
|
||||||
|
avgTime: "02:55",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 5,
|
||||||
|
title: "Field Notes from Copenhagen Design Week",
|
||||||
|
author: "Noah Bennett",
|
||||||
|
published: "Oct 21",
|
||||||
|
pageviews: "19.7k",
|
||||||
|
avgTime: "03:18",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 6,
|
||||||
|
title: "A Studio Visit with Milan's Most Elusive Lighting Designer",
|
||||||
|
author: "Claire Duval",
|
||||||
|
published: "Oct 09",
|
||||||
|
pageviews: "17.4k",
|
||||||
|
avgTime: "04:02",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 7,
|
||||||
|
title: "Collecting the New Avant-Garde in Contemporary Furniture",
|
||||||
|
author: "Tommy Rhodes",
|
||||||
|
published: "Sep 30",
|
||||||
|
pageviews: "15.9k",
|
||||||
|
avgTime: "03:36",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 8,
|
||||||
|
title: "Inside Lisbon's Quiet Culinary Renaissance",
|
||||||
|
author: "Amara Iqbal",
|
||||||
|
published: "Oct 14",
|
||||||
|
pageviews: "14.2k",
|
||||||
|
avgTime: "05:08",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 9,
|
||||||
|
title: "Why Slow Interiors Are Defining the Next Luxury Wave",
|
||||||
|
author: "Henry Vale",
|
||||||
|
published: "Oct 03",
|
||||||
|
pageviews: "12.7k",
|
||||||
|
avgTime: "03:11",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 10,
|
||||||
|
title: "The Return of Print: Independent Magazine Covers to Watch",
|
||||||
|
author: "Mina Okafor",
|
||||||
|
published: "Sep 26",
|
||||||
|
pageviews: "11.3k",
|
||||||
|
avgTime: "02:49",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
type TopEditorialProps = React.ComponentProps<typeof Card> & {
|
||||||
|
selectedMetric?: EditorialMetric
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopEditorial({
|
||||||
|
selectedMetric = "views",
|
||||||
|
...props
|
||||||
|
}: TopEditorialProps) {
|
||||||
|
const [visibleCount, setVisibleCount] = React.useState(5)
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
|
||||||
|
const hasMoreRows = visibleCount < EDITORIAL_ROWS.length
|
||||||
|
const visibleRows = EDITORIAL_ROWS.slice(0, visibleCount)
|
||||||
|
|
||||||
|
const handleLoadMore = React.useCallback(() => {
|
||||||
|
if (!hasMoreRows || isLoadingMore) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingMore(true)
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setVisibleCount(EDITORIAL_ROWS.length)
|
||||||
|
setIsLoadingMore(false)
|
||||||
|
}, 2000)
|
||||||
|
}, [hasMoreRows, isLoadingMore])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card {...props}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-(--gap) sm:flex-row">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<CardTitle className="text-2xl">Top Editorials</CardTitle>
|
||||||
|
<CardDescription>Ranked by engagement</CardDescription>
|
||||||
|
</div>
|
||||||
|
<ToggleGroup
|
||||||
|
aria-label="Top editorials metric selector"
|
||||||
|
value={[selectedMetric]}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:ml-auto sm:w-fit"
|
||||||
|
>
|
||||||
|
{(["views", "time", "shares"] as const).map((metric) => {
|
||||||
|
return (
|
||||||
|
<ToggleGroupItem key={metric} value={metric} className="flex-1">
|
||||||
|
{METRIC_LABEL[metric]}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 **:data-[slot=table-container]:no-scrollbar **:data-[slot=table-container]:overflow-y-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Published</TableHead>
|
||||||
|
<TableHead>Page Views</TableHead>
|
||||||
|
<TableHead>Read Time</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{visibleRows.map((row) => (
|
||||||
|
<TableRow key={row.rank}>
|
||||||
|
<TableCell className="translate-y-1 align-text-top">
|
||||||
|
{row.rank}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="font-heading text-xl tracking-tight text-foreground">
|
||||||
|
{row.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||||
|
By {row.author}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.published}</TableCell>
|
||||||
|
<TableCell>{row.pageviews}</TableCell>
|
||||||
|
<TableCell>{row.avgTime}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={<Button variant="ghost" size="icon-xs" />}
|
||||||
|
aria-label={`Open actions for ${row.title}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Publish</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem variant="destructive">
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-center">
|
||||||
|
{hasMoreRows ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
>
|
||||||
|
Load more content{" "}
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<Spinner data-icon="inline-end" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownIcon data-icon="inline-end" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/styles/base-sera/ui/card"
|
||||||
|
|
||||||
|
const TrafficOverviewContent = dynamic(
|
||||||
|
() => import("./traffic-overview").then((mod) => mod.TrafficOverview),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <TrafficOverviewFallback />,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export function TrafficOverviewDeferred({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Card>) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<TrafficOverviewContent {...props} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrafficOverviewFallback() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">Traffic Overview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Traffic for the last 30 days has increased by 12.4% compared to the
|
||||||
|
previous period.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-82 w-full flex-col justify-end gap-6 overflow-hidden bg-muted/40 p-5"
|
||||||
|
>
|
||||||
|
<div className="h-px w-full bg-border" />
|
||||||
|
<div className="h-px w-full bg-border" />
|
||||||
|
<div className="h-px w-full bg-border" />
|
||||||
|
<div className="h-px w-full bg-border" />
|
||||||
|
<div className="h-px w-full bg-border" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { TrendingUpIcon } from "lucide-react"
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ReferenceDot,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts"
|
||||||
|
|
||||||
|
import { Badge } from "@/styles/base-sera/ui/badge"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/styles/base-sera/ui/card"
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from "@/styles/base-sera/ui/chart"
|
||||||
|
|
||||||
|
const TRAFFIC_OVERVIEW_DATA = [
|
||||||
|
{ date: "2025-10-01", views: 2600, unique: 1600 },
|
||||||
|
{ date: "2025-10-04", views: 4500, unique: 3000 },
|
||||||
|
{ date: "2025-10-08", views: 3500, unique: 2500 },
|
||||||
|
{ date: "2025-10-10", views: 6400, unique: 4500 },
|
||||||
|
{ date: "2025-10-13", views: 5400, unique: 4000 },
|
||||||
|
{ date: "2025-10-15", views: 8300, unique: 6500 },
|
||||||
|
{ date: "2025-10-17", views: 7400, unique: 6000 },
|
||||||
|
{ date: "2025-10-18", views: 9240, unique: 7105 },
|
||||||
|
{ date: "2025-10-22", views: 7700, unique: 6400 },
|
||||||
|
{ date: "2025-10-26", views: 8800, unique: 7000 },
|
||||||
|
{ date: "2025-10-29", views: 9800, unique: 8400 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TRAFFIC_CHART_CONFIG = {
|
||||||
|
views: {
|
||||||
|
label: "Views",
|
||||||
|
theme: {
|
||||||
|
light: "var(--chart-5)",
|
||||||
|
dark: "var(--chart-1)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unique: {
|
||||||
|
label: "Unique",
|
||||||
|
theme: {
|
||||||
|
light: "var(--chart-1)",
|
||||||
|
dark: "var(--chart-2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
const X_AXIS_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatYAxisTick(value: number) {
|
||||||
|
if (value === 0) {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value % 1000 === 0) {
|
||||||
|
return `${value / 1000}k`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value / 1000}k`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatXAxisTick(value: string) {
|
||||||
|
const date = new Date(`${value}T00:00:00Z`)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return X_AXIS_DATE_FORMATTER.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrafficOverview({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Card>) {
|
||||||
|
return (
|
||||||
|
<Card {...props}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">Traffic Overview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Traffic for the last 30 days has increased by 12.4% compared to the
|
||||||
|
previous period.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={TRAFFIC_CHART_CONFIG} className="h-82 w-full">
|
||||||
|
<LineChart data={TRAFFIC_OVERVIEW_DATA}>
|
||||||
|
<CartesianGrid
|
||||||
|
vertical={false}
|
||||||
|
strokeDasharray="3 6"
|
||||||
|
stroke="var(--border)"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
tickMargin={10}
|
||||||
|
tickFormatter={formatXAxisTick}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={44}
|
||||||
|
domain={[0, 10000]}
|
||||||
|
ticks={[0, 2500, 5000, 7500, 10000]}
|
||||||
|
tickFormatter={formatYAxisTick}
|
||||||
|
hide
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Line
|
||||||
|
type="linear"
|
||||||
|
dataKey="views"
|
||||||
|
stroke="var(--color-views)"
|
||||||
|
strokeWidth={2.2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 3.5, fill: "var(--color-views)" }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="linear"
|
||||||
|
dataKey="unique"
|
||||||
|
stroke="var(--color-unique)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 6"
|
||||||
|
dot={false}
|
||||||
|
activeDot={false}
|
||||||
|
/>
|
||||||
|
<ReferenceDot
|
||||||
|
x="2025-10-18"
|
||||||
|
y={9240}
|
||||||
|
r={2.5}
|
||||||
|
fill="var(--color-views)"
|
||||||
|
stroke="var(--color-views)"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
apps/v4/app/(app)/(styles)/sera/audience-analytics/index.tsx
Normal file
22
apps/v4/app/(app)/(styles)/sera/audience-analytics/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Separator } from "@/styles/base-sera/ui/separator"
|
||||||
|
|
||||||
|
import { Demographics } from "./components/demographics"
|
||||||
|
import { MetricsGrid } from "./components/metrics-grid"
|
||||||
|
import { PreviewHeader } from "./components/preview-header"
|
||||||
|
import { TopEditorial } from "./components/top-editorial"
|
||||||
|
import { TrafficOverviewDeferred } from "./components/traffic-overview-deferred"
|
||||||
|
|
||||||
|
export function AudienceAnalytics() {
|
||||||
|
return (
|
||||||
|
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
|
||||||
|
<PreviewHeader />
|
||||||
|
<Separator className="hidden sm:block" />
|
||||||
|
<div className="container grid grid-cols-12 gap-(--gap) py-(--gap)">
|
||||||
|
<MetricsGrid />
|
||||||
|
<TrafficOverviewDeferred className="col-span-full md:col-span-6 lg:col-span-8" />
|
||||||
|
<Demographics className="col-span-full md:col-span-6 lg:col-span-4" />
|
||||||
|
<TopEditorial className="col-span-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
apps/v4/app/(app)/(styles)/sera/components/image-preview.tsx
Normal file
46
apps/v4/app/(app)/(styles)/sera/components/image-preview.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function ImagePreview() {
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex flex-col overflow-hidden md:hidden">
|
||||||
|
<ImagePreviewItem name="sera-01" />
|
||||||
|
<ImagePreviewItem name="sera-03" />
|
||||||
|
<ImagePreviewItem name="sera-02" />
|
||||||
|
<ImagePreviewItem name="sera-06" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImagePreviewItem({
|
||||||
|
name,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"theme-taupe overflow-hidden bg-muted px-4 py-2 first:pt-4 last:pb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={`/images/${name}-light.png`}
|
||||||
|
alt={name}
|
||||||
|
width={1440}
|
||||||
|
height={900}
|
||||||
|
className="dark:hidden"
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src={`/images/${name}-dark.png`}
|
||||||
|
alt={name}
|
||||||
|
width={1440}
|
||||||
|
height={900}
|
||||||
|
className="hidden dark:block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
apps/v4/app/(app)/(styles)/sera/components/lazy-preview.tsx
Normal file
148
apps/v4/app/(app)/(styles)/sera/components/lazy-preview.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
|
|
||||||
|
type LazyPreviewName =
|
||||||
|
| "articleDirectory"
|
||||||
|
| "emptyState"
|
||||||
|
| "editArticle"
|
||||||
|
| "mediaLibrary"
|
||||||
|
| "mediaLibraryTable"
|
||||||
|
|
||||||
|
const PREVIEW_MIN_HEIGHTS: Record<LazyPreviewName, number> = {
|
||||||
|
articleDirectory: 760,
|
||||||
|
emptyState: 560,
|
||||||
|
editArticle: 980,
|
||||||
|
mediaLibrary: 880,
|
||||||
|
mediaLibraryTable: 980,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArticleDirectoryPreview = dynamic(
|
||||||
|
() => import("../article-directory").then((mod) => mod.ArticleDirectory),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.articleDirectory} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const EmptyStatePreview = dynamic(
|
||||||
|
() => import("../empty-state").then((mod) => mod.EmptyState),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.emptyState} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const EditArticlePreview = dynamic(
|
||||||
|
() => import("../edit-article").then((mod) => mod.EditArticle),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.editArticle} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const MediaLibraryPreview = dynamic(
|
||||||
|
() => import("../media-library").then((mod) => mod.MediaLibrary),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.mediaLibrary} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const MediaLibraryTablePreview = dynamic(
|
||||||
|
() => import("../media-library-table").then((mod) => mod.MediaLibraryTable),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.mediaLibraryTable} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const PREVIEW_COMPONENTS: Record<LazyPreviewName, React.ComponentType> = {
|
||||||
|
articleDirectory: ArticleDirectoryPreview,
|
||||||
|
emptyState: EmptyStatePreview,
|
||||||
|
editArticle: EditArticlePreview,
|
||||||
|
mediaLibrary: MediaLibraryPreview,
|
||||||
|
mediaLibraryTable: MediaLibraryTablePreview,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LazyPreview({ name }: { name: LazyPreviewName }) {
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [shouldRender, setShouldRender] = React.useState(false)
|
||||||
|
const PreviewComponent = PREVIEW_COMPONENTS[name]
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (shouldRender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
|
||||||
|
if (!container || !("IntersectionObserver" in window)) {
|
||||||
|
setShouldRender(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setShouldRender(true)
|
||||||
|
observer.disconnect()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: "800px 0px",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(container)
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [shouldRender])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef}>
|
||||||
|
{shouldRender ? (
|
||||||
|
<PreviewComponent />
|
||||||
|
) : (
|
||||||
|
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS[name]} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewPlaceholder({ minHeight }: { minHeight: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="preview theme-taupe @container/preview w-full flex-1 bg-muted p-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:p-6 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)]"
|
||||||
|
style={{ minHeight }}
|
||||||
|
>
|
||||||
|
<div className="container flex flex-col gap-(--gap) py-(--gap)">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="h-5 w-44 bg-background/80" />
|
||||||
|
<div className="h-3 w-56 max-w-full bg-background/60" />
|
||||||
|
</div>
|
||||||
|
<div className="hidden h-8 w-28 bg-background/70 sm:block" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-(--gap) md:grid-cols-3">
|
||||||
|
<div className="min-h-48 bg-background/70 md:col-span-2" />
|
||||||
|
<div className="min-h-48 bg-background/70" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const THEME_OPTIONS = [
|
||||||
|
{ label: "Taupe", value: "theme-taupe" },
|
||||||
|
{ label: "Neutral", value: "theme-neutral" },
|
||||||
|
{ label: "Stone", value: "theme-stone" },
|
||||||
|
{ label: "Zinc", value: "theme-zinc" },
|
||||||
|
{ label: "Mauve", value: "theme-mauve" },
|
||||||
|
{ label: "Olive", value: "theme-olive" },
|
||||||
|
{ label: "Mist", value: "theme-mist" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const DEFAULT_THEME = "theme-taupe"
|
||||||
|
|
||||||
|
function applyThemeToPreviews(theme: string) {
|
||||||
|
const previewElements = document.querySelectorAll<HTMLElement>(".preview")
|
||||||
|
|
||||||
|
previewElements.forEach((element) => {
|
||||||
|
THEME_OPTIONS.forEach((option) => {
|
||||||
|
element.classList.remove(option.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
element.classList.add(theme)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeSwitcher() {
|
||||||
|
const [theme, setTheme] = React.useState<string>(DEFAULT_THEME)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
applyThemeToPreviews(theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-x-0 bottom-8 z-50 flex justify-center px-4">
|
||||||
|
<div className="w-full max-w-[60vw] rounded-full border-0 bg-neutral-950/50 p-1.5 shadow-xl backdrop-blur-xl sm:max-w-fit">
|
||||||
|
<div className="no-scrollbar flex snap-x snap-mandatory items-center overflow-x-auto">
|
||||||
|
{THEME_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
data-active={theme === option.value}
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTheme(option.value)
|
||||||
|
}}
|
||||||
|
className="shrink-0 snap-center rounded-full px-3 py-1.5 text-sm font-medium text-neutral-300 outline-hidden transition-colors select-none hover:text-neutral-100 data-active:bg-neutral-500 data-active:text-neutral-100"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlignCenterIcon,
|
||||||
|
AlignLeftIcon,
|
||||||
|
AlignRightIcon,
|
||||||
|
BoldIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
Code2Icon,
|
||||||
|
Heading1Icon,
|
||||||
|
Heading2Icon,
|
||||||
|
Heading3Icon,
|
||||||
|
ImageIcon,
|
||||||
|
ItalicIcon,
|
||||||
|
LinkIcon,
|
||||||
|
ListIcon,
|
||||||
|
ListOrderedIcon,
|
||||||
|
RedoIcon,
|
||||||
|
StrikethroughIcon,
|
||||||
|
TypeIcon,
|
||||||
|
UnderlineIcon,
|
||||||
|
UndoIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
} from "@/styles/base-sera/ui/button-group"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/styles/base-sera/ui/card"
|
||||||
|
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/styles/base-sera/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@/styles/base-sera/ui/field"
|
||||||
|
import { Input } from "@/styles/base-sera/ui/input"
|
||||||
|
import {
|
||||||
|
Progress,
|
||||||
|
ProgressLabel,
|
||||||
|
ProgressValue,
|
||||||
|
} from "@/styles/base-sera/ui/progress"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/styles/base-sera/ui/select"
|
||||||
|
import { Textarea } from "@/styles/base-sera/ui/textarea"
|
||||||
|
|
||||||
|
type Milestone = {
|
||||||
|
name: string
|
||||||
|
complete: boolean
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MILESTONES: Milestone[] = [
|
||||||
|
{
|
||||||
|
name: "Outline & Commissioning",
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "First Draft Submitted",
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Review & Revisions",
|
||||||
|
complete: false,
|
||||||
|
note: "Waiting on editor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Final Copy Edit",
|
||||||
|
complete: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Art Direction & Layout",
|
||||||
|
complete: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ISSUES = [
|
||||||
|
{ label: "Spring Issue 2024", value: "spring-2024" },
|
||||||
|
{ label: "Summer Issue 2024", value: "summer-2024" },
|
||||||
|
{ label: "Autumn Issue 2024", value: "autumn-2024" },
|
||||||
|
{ label: "Winter Issue 2024", value: "winter-2024" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function EditorWorkspace() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||||
|
<section className="flex flex-col border border-border/70 bg-background">
|
||||||
|
<div className="flex border-b p-2">
|
||||||
|
<ButtonGroup>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
Normal Text
|
||||||
|
<ChevronDownIcon data-icon="inline-end" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<TypeIcon />
|
||||||
|
Normal Text
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Heading1Icon />
|
||||||
|
Heading 1
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Heading2Icon />
|
||||||
|
Heading 2
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Heading3Icon />
|
||||||
|
Heading 3
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<ListIcon />
|
||||||
|
Bullet List
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<ListOrderedIcon />
|
||||||
|
Numbered List
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<ButtonGroupSeparator className="mx-2 data-vertical:h-4 data-vertical:self-center" />
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Bold">
|
||||||
|
<BoldIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Italic">
|
||||||
|
<ItalicIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Underline">
|
||||||
|
<UnderlineIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Strikethrough"
|
||||||
|
className="hidden md:flex"
|
||||||
|
>
|
||||||
|
<StrikethroughIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Code"
|
||||||
|
className="hidden md:flex"
|
||||||
|
>
|
||||||
|
<Code2Icon />
|
||||||
|
</Button>
|
||||||
|
<ButtonGroupSeparator className="mx-2 hidden md:flex data-vertical:h-4 data-vertical:self-center" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Align Left"
|
||||||
|
className="hidden md:flex"
|
||||||
|
>
|
||||||
|
<AlignLeftIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Align Center"
|
||||||
|
className="hidden md:flex"
|
||||||
|
>
|
||||||
|
<AlignCenterIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Align Right"
|
||||||
|
className="hidden md:flex"
|
||||||
|
>
|
||||||
|
<AlignRightIcon />
|
||||||
|
</Button>
|
||||||
|
<ButtonGroupSeparator className="mx-2 hidden md:flex data-vertical:h-4 data-vertical:self-center" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Link"
|
||||||
|
className="hidden md:flex"
|
||||||
|
>
|
||||||
|
<LinkIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Image"
|
||||||
|
className="hidden md:flex"
|
||||||
|
>
|
||||||
|
<ImageIcon />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup className="ml-auto">
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Undo">
|
||||||
|
<UndoIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="Redo">
|
||||||
|
<RedoIcon />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto flex max-w-2xl flex-1 flex-col gap-8 px-10 py-10 leading-loose md:px-14 lg:py-18">
|
||||||
|
<h1 className="font-heading text-4xl leading-12 font-medium tracking-wide uppercase">
|
||||||
|
The Future of Sustainable Architecture
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
As cities continue to expand at an unprecedented rate, the
|
||||||
|
architectural paradigm is shifting from mere expansion to
|
||||||
|
sustainable integration. The concrete jungles of the 20th century
|
||||||
|
are making way for structures that breathe, adapt, and give back to
|
||||||
|
their environments.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Historically, urban development has been a zero-sum game with
|
||||||
|
nature.
|
||||||
|
</p>
|
||||||
|
<h2 className="font-heading text-2xl tracking-wide uppercase">
|
||||||
|
The Living Building Challenge
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Sterling's latest project in downtown Seattle is a testament to
|
||||||
|
this new philosophy. "We are no longer designing static
|
||||||
|
structures," Sterling explained during a recent site visit.
|
||||||
|
"We are engineering localized ecosystems."
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The building features a facade of responsive biomaterials that
|
||||||
|
adjust their porosity based on humidity and temperature,
|
||||||
|
significantly reducing the need for artificial climate control.
|
||||||
|
Rainwater is not merely channeled away but captured, filtered
|
||||||
|
through a series of integrated rooftop wetlands, and reused within
|
||||||
|
the building's greywater system.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This shift requires more than just innovative materials; it demands
|
||||||
|
a fundamental change in how we value space. Check with engineering
|
||||||
|
team for specific stats.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<aside className="grid grid-cols-12 gap-(--gap) xl:flex xl:flex-col">
|
||||||
|
<Card className="col-span-full md:col-span-6 lg:col-span-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Article Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Issue</FieldLabel>
|
||||||
|
<Select items={ISSUES} defaultValue="summer-2024">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{ISSUES.map((issue) => (
|
||||||
|
<SelectItem key={issue.value} value={issue.value}>
|
||||||
|
{issue.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Author</FieldLabel>
|
||||||
|
<Input defaultValue="Elena Rostova" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="col-span-full md:col-span-6 lg:col-span-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Publication Flow</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup>
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend>Required Milestones</FieldLegend>
|
||||||
|
<Field>
|
||||||
|
{MILESTONES.map((milestone) => (
|
||||||
|
<Field key={milestone.name} orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked={milestone.complete}
|
||||||
|
name={milestone.name}
|
||||||
|
id={milestone.name}
|
||||||
|
/>
|
||||||
|
<FieldLabel htmlFor={milestone.name}>
|
||||||
|
{milestone.name}
|
||||||
|
</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</Field>
|
||||||
|
</FieldSet>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Add note for editor</FieldLabel>
|
||||||
|
<Textarea placeholder="This article needs to be revised for clarity and accuracy." />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="col-span-full lg:col-span-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Word Count</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Progress value={70}>
|
||||||
|
<ProgressLabel>1,402 / 2,000 words</ProgressLabel>
|
||||||
|
<ProgressValue />
|
||||||
|
</Progress>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/styles/base-sera/ui/badge"
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
} from "@/styles/base-sera/ui/breadcrumb"
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
|
||||||
|
|
||||||
|
export function PreviewHeader() {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-2 text-center sm:text-left">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
|
||||||
|
<ArrowLeftIcon className="size-3.5" />
|
||||||
|
Back to articles
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
|
||||||
|
EDIT ARTICLE
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<ButtonGroup className="gap-2 md:gap-4">
|
||||||
|
<Badge title="2 minutes ago">Autosaved</Badge>
|
||||||
|
<ButtonGroup className="gap-2 md:gap-4">
|
||||||
|
<Button variant="link">
|
||||||
|
Preview
|
||||||
|
<ExternalLinkIcon data-icon="inline-end" />
|
||||||
|
</Button>
|
||||||
|
<Button>Submit Draft</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
apps/v4/app/(app)/(styles)/sera/edit-article/index.tsx
Normal file
16
apps/v4/app/(app)/(styles)/sera/edit-article/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Separator } from "@/styles/base-sera/ui/separator"
|
||||||
|
|
||||||
|
import { EditorWorkspace } from "./components/editor-workspace"
|
||||||
|
import { PreviewHeader } from "./components/preview-header"
|
||||||
|
|
||||||
|
export function EditArticle() {
|
||||||
|
return (
|
||||||
|
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
|
||||||
|
<PreviewHeader />
|
||||||
|
<Separator className="hidden sm:block" />
|
||||||
|
<div className="container py-(--gap)">
|
||||||
|
<EditorWorkspace />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { FileTextIcon, PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/styles/base-sera/ui/badge"
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import { Card, CardContent } from "@/styles/base-sera/ui/card"
|
||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@/styles/base-sera/ui/empty"
|
||||||
|
import { Separator } from "@/styles/base-sera/ui/separator"
|
||||||
|
|
||||||
|
type Stage = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
dotClassName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGES: Stage[] = [
|
||||||
|
{
|
||||||
|
id: "drafting",
|
||||||
|
label: "Drafting",
|
||||||
|
description:
|
||||||
|
"Start the writing process. Articles here are works in progress, visible only to editors and authors.",
|
||||||
|
dotClassName: "bg-amber-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "in-revision",
|
||||||
|
label: "In Revision",
|
||||||
|
description:
|
||||||
|
"Content undergoing editorial review. Track changes and word counts as pieces take shape.",
|
||||||
|
dotClassName: "bg-orange-700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "final-edit",
|
||||||
|
label: "Final Edit",
|
||||||
|
description:
|
||||||
|
"The final polish before publication. Ensure all styling and factual checks are complete.",
|
||||||
|
dotClassName: "bg-foreground",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function EmptyDirectory() {
|
||||||
|
return (
|
||||||
|
<Card className="py-24">
|
||||||
|
<CardContent className="flex flex-col items-center gap-10">
|
||||||
|
<Empty className="min-h-96">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia
|
||||||
|
variant="icon"
|
||||||
|
className="size-14 rounded-full bg-muted/70 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<FileTextIcon className="size-5" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle className="font-heading text-2xl tracking-normal normal-case">
|
||||||
|
A Blank Canvas
|
||||||
|
</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Your editorial directory is currently empty. Start building your
|
||||||
|
publication's next issue by drafting the first piece.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<EmptyContent>
|
||||||
|
<Button>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
Create first article
|
||||||
|
</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
<Separator className="max-w-2xl" />
|
||||||
|
<div className="grid w-full max-w-2xl grid-cols-1 gap-8 sm:grid-cols-3">
|
||||||
|
{STAGES.map((stage) => (
|
||||||
|
<div key={stage.id} className="flex flex-col gap-2">
|
||||||
|
<Badge>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`size-1.5 rounded-full ${stage.dotClassName}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{stage.label}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{stage.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ArrowLeftIcon, PlusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
} from "@/styles/base-sera/ui/breadcrumb"
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
|
||||||
|
export function PreviewHeader() {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-2 text-center sm:text-left">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
|
||||||
|
<ArrowLeftIcon className="size-3.5" />
|
||||||
|
Editorial Dashboard
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
|
||||||
|
Article Directory
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Button className="sm:ml-auto">
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
New Article
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
apps/v4/app/(app)/(styles)/sera/empty-state/index.tsx
Normal file
16
apps/v4/app/(app)/(styles)/sera/empty-state/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Separator } from "@/styles/base-sera/ui/separator"
|
||||||
|
|
||||||
|
import { EmptyDirectory } from "./components/empty-directory"
|
||||||
|
import { PreviewHeader } from "./components/preview-header"
|
||||||
|
|
||||||
|
export function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
|
||||||
|
<PreviewHeader />
|
||||||
|
<Separator className="hidden sm:block" />
|
||||||
|
<div className="container py-(--gap)">
|
||||||
|
<EmptyDirectory />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
FileTextIcon,
|
||||||
|
ImageIcon,
|
||||||
|
MoreVerticalIcon,
|
||||||
|
SearchIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/styles/base-sera/ui/badge"
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
} from "@/styles/base-sera/ui/card"
|
||||||
|
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/styles/base-sera/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@/styles/base-sera/ui/input-group"
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/styles/base-sera/ui/pagination"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/styles/base-sera/ui/table"
|
||||||
|
|
||||||
|
import { ASSETS, type AssetType } from "../../media-library/data"
|
||||||
|
|
||||||
|
function AssetTypeIcon({
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
type: AssetType
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
if (type === "MP4") {
|
||||||
|
return <VideoIcon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "PDF") {
|
||||||
|
return <FileTextIcon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ImageIcon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetTable() {
|
||||||
|
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(
|
||||||
|
new Set(["1"])
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleSelection = React.useCallback((id: string) => {
|
||||||
|
setSelectedIds((previous) => {
|
||||||
|
const next = new Set(previous)
|
||||||
|
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput placeholder="Search files, tags, or metadata..." />
|
||||||
|
</InputGroup>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-0 py-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10 pl-6" aria-label="Select" />
|
||||||
|
<TableHead className="w-20" aria-label="Preview" />
|
||||||
|
<TableHead>Filename</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Dimensions</TableHead>
|
||||||
|
<TableHead>Size</TableHead>
|
||||||
|
<TableHead>Uploaded By</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className="w-10 pr-6" aria-label="Actions" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{ASSETS.map((asset) => {
|
||||||
|
const isSelected = selectedIds.has(asset.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={asset.id}
|
||||||
|
data-state={isSelected ? "selected" : undefined}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => toggleSelection(asset.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="pl-6">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
aria-label={`Select ${asset.name}`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onCheckedChange={() => toggleSelection(asset.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="relative flex aspect-4/3 w-16 items-center justify-center bg-muted/60 ring-1 ring-border/70 ring-inset">
|
||||||
|
{asset.duration ? (
|
||||||
|
<span className="absolute right-1 bottom-1 bg-foreground/90 px-1 text-[0.5rem] font-semibold tracking-wider text-background">
|
||||||
|
{asset.duration}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<AssetTypeIcon
|
||||||
|
type={asset.type}
|
||||||
|
className="size-4 text-muted-foreground/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium text-foreground">
|
||||||
|
{asset.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border px-2 py-0.5 text-[0.625rem]"
|
||||||
|
>
|
||||||
|
{asset.type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{asset.dimensions}</TableCell>
|
||||||
|
<TableCell className="text-sm">{asset.size}</TableCell>
|
||||||
|
<TableCell>{asset.uploadedBy}</TableCell>
|
||||||
|
<TableCell className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
|
||||||
|
{asset.date}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="pr-6 text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={<Button variant="ghost" size="icon-xs" />}
|
||||||
|
aria-label={`Open actions for ${asset.name}`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>Preview</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Download</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem variant="destructive">
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-center py-4">
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious href="#" text="" />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href="#" isActive>
|
||||||
|
1
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href="#">2</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href="#">3</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext href="#" text="" />
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { addDays, format } from "date-fns"
|
||||||
|
import { CalendarIcon, FilterIcon, XIcon } from "lucide-react"
|
||||||
|
import { type DateRange } from "react-day-picker"
|
||||||
|
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import { Calendar } from "@/styles/base-sera/ui/calendar"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/styles/base-sera/ui/card"
|
||||||
|
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxChip,
|
||||||
|
ComboboxChips,
|
||||||
|
ComboboxChipsInput,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxValue,
|
||||||
|
useComboboxAnchor,
|
||||||
|
} from "@/styles/base-sera/ui/combobox"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@/styles/base-sera/ui/field"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/styles/base-sera/ui/popover"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/styles/base-sera/ui/radio-group"
|
||||||
|
import { Slider } from "@/styles/base-sera/ui/slider"
|
||||||
|
|
||||||
|
const FILE_TYPES = [
|
||||||
|
{
|
||||||
|
id: "images",
|
||||||
|
label: "Images (JPEG, PNG, WEBP)",
|
||||||
|
defaultChecked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "video",
|
||||||
|
label: "Video (MP4, MOV)",
|
||||||
|
defaultChecked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "documents",
|
||||||
|
label: "Documents (PDF)",
|
||||||
|
defaultChecked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "audio",
|
||||||
|
label: "Audio (MP3, WAV)",
|
||||||
|
defaultChecked: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const DATE_OPTIONS = [
|
||||||
|
{ value: "any", label: "Any time" },
|
||||||
|
{ value: "24h", label: "Past 24 hours" },
|
||||||
|
{ value: "week", label: "Past week" },
|
||||||
|
{ value: "month", label: "Past month" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TAGS = [
|
||||||
|
"architecture",
|
||||||
|
"brutalism",
|
||||||
|
"ceramics",
|
||||||
|
"design-week",
|
||||||
|
"editorial",
|
||||||
|
"exterior",
|
||||||
|
"film",
|
||||||
|
"food",
|
||||||
|
"furniture",
|
||||||
|
"interior",
|
||||||
|
"kyoto",
|
||||||
|
"minimalism",
|
||||||
|
"print",
|
||||||
|
"sustainability",
|
||||||
|
"summer-issue",
|
||||||
|
"video",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function FilterLibrary() {
|
||||||
|
const tagAnchor = useComboboxAnchor()
|
||||||
|
const [dateRange, setDateRange] = React.useState<DateRange | undefined>({
|
||||||
|
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
|
||||||
|
to: addDays(
|
||||||
|
new Date(new Date().getFullYear(), new Date().getMonth(), 1),
|
||||||
|
21
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle>Filter Library</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup>
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend>File Type</FieldLegend>
|
||||||
|
<Field>
|
||||||
|
{FILE_TYPES.map((type) => (
|
||||||
|
<Field key={type.id} orientation="horizontal">
|
||||||
|
<Checkbox id={type.id} defaultChecked={type.defaultChecked} />
|
||||||
|
<FieldLabel htmlFor={type.id}>{type.label}</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</Field>
|
||||||
|
</FieldSet>
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend>Date Uploaded</FieldLegend>
|
||||||
|
<RadioGroup defaultValue="any">
|
||||||
|
{DATE_OPTIONS.map((option) => (
|
||||||
|
<Field key={option.value} orientation="horizontal">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={option.value}
|
||||||
|
id={`date-${option.value}`}
|
||||||
|
/>
|
||||||
|
<FieldLabel htmlFor={`date-${option.value}`}>
|
||||||
|
{option.label}
|
||||||
|
</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</FieldSet>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="custom-range">Custom Range</FieldLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
id="custom-range"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CalendarIcon data-icon="inline-start" />
|
||||||
|
{dateRange?.from ? (
|
||||||
|
dateRange.to ? (
|
||||||
|
<>
|
||||||
|
{format(dateRange.from, "LLL dd, y")} –{" "}
|
||||||
|
{format(dateRange.to, "LLL dd, y")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
format(dateRange.from, "LLL dd, y")
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span>Pick a date range</span>
|
||||||
|
)}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="end">
|
||||||
|
<Calendar
|
||||||
|
mode="range"
|
||||||
|
defaultMonth={dateRange?.from}
|
||||||
|
selected={dateRange}
|
||||||
|
onSelect={setDateRange}
|
||||||
|
numberOfMonths={2}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</Field>
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend>File Size</FieldLegend>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
||||||
|
<span>0 MB</span>
|
||||||
|
<span>500+ MB</span>
|
||||||
|
</div>
|
||||||
|
<Slider defaultValue={[0, 60]} max={100} step={1} />
|
||||||
|
<div className="flex items-center justify-between text-xs font-medium">
|
||||||
|
<span>Min: 0 MB</span>
|
||||||
|
<span>Max: 300 MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FieldSet>
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend>Tags</FieldLegend>
|
||||||
|
<Field>
|
||||||
|
<Combobox
|
||||||
|
multiple
|
||||||
|
autoHighlight
|
||||||
|
items={TAGS}
|
||||||
|
defaultValue={["architecture", "brutalism"]}
|
||||||
|
>
|
||||||
|
<ComboboxChips ref={tagAnchor}>
|
||||||
|
<ComboboxValue>
|
||||||
|
{(values) => (
|
||||||
|
<React.Fragment>
|
||||||
|
{values.map((value: string) => (
|
||||||
|
<ComboboxChip key={value}>{value}</ComboboxChip>
|
||||||
|
))}
|
||||||
|
<ComboboxChipsInput placeholder="Filter by tag..." />
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</ComboboxValue>
|
||||||
|
</ComboboxChips>
|
||||||
|
<ComboboxContent anchor={tagAnchor}>
|
||||||
|
<ComboboxEmpty>No tags found.</ComboboxEmpty>
|
||||||
|
<ComboboxList>
|
||||||
|
{(item) => (
|
||||||
|
<ComboboxItem key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</ComboboxItem>
|
||||||
|
)}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</Combobox>
|
||||||
|
</Field>
|
||||||
|
</FieldSet>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-2 border-t">
|
||||||
|
<Button className="w-full">Apply Filters</Button>
|
||||||
|
<Button variant="ghost" className="w-full">
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ArrowLeftIcon, SlidersHorizontalIcon, UploadIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
} from "@/styles/base-sera/ui/breadcrumb"
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
|
||||||
|
|
||||||
|
export function PreviewHeader() {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-2 text-center sm:text-left">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
|
||||||
|
<ArrowLeftIcon className="size-3.5" />
|
||||||
|
Asset management
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
|
||||||
|
Media Library
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="bg-background hover:bg-background/80"
|
||||||
|
>
|
||||||
|
<SlidersHorizontalIcon data-icon="inline-start" />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<UploadIcon data-icon="inline-start" />
|
||||||
|
Upload Assets
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Separator } from "@/styles/base-sera/ui/separator"
|
||||||
|
|
||||||
|
import { AssetTable } from "./components/asset-table"
|
||||||
|
import { FilterLibrary } from "./components/filter-library"
|
||||||
|
import { PreviewHeader } from "./components/preview-header"
|
||||||
|
|
||||||
|
export function MediaLibraryTable() {
|
||||||
|
return (
|
||||||
|
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
|
||||||
|
<PreviewHeader />
|
||||||
|
<Separator className="hidden sm:block" />
|
||||||
|
<div className="container grid grid-cols-1 items-start gap-(--gap) py-(--gap) xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<AssetTable />
|
||||||
|
<FilterLibrary />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
DownloadIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
ImageIcon,
|
||||||
|
PlusIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/styles/base-sera/ui/badge"
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import { Card, CardContent, CardFooter } from "@/styles/base-sera/ui/card"
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@/styles/base-sera/ui/item"
|
||||||
|
import { Separator } from "@/styles/base-sera/ui/separator"
|
||||||
|
|
||||||
|
import { type Asset, type AssetType } from "../data"
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<AssetType, string> = {
|
||||||
|
JPEG: "Image / JPEG",
|
||||||
|
PNG: "Image / PNG",
|
||||||
|
WEBP: "Image / WEBP",
|
||||||
|
MP4: "Video / MP4",
|
||||||
|
PDF: "Document / PDF",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetDetails({ asset }: { asset: Asset }) {
|
||||||
|
return (
|
||||||
|
<Card className="gap-0">
|
||||||
|
<CardContent className="flex flex-col gap-6">
|
||||||
|
<div className="flex aspect-5/4 items-center justify-center bg-muted/60 text-muted-foreground/60 ring-1 ring-border/70 ring-inset">
|
||||||
|
{asset.type === "MP4" ? (
|
||||||
|
<VideoIcon className="size-8" />
|
||||||
|
) : asset.type === "PDF" ? (
|
||||||
|
<FileTextIcon className="size-8" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="size-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="line-clamp-2 font-heading text-xl tracking-wide">
|
||||||
|
{asset.name}
|
||||||
|
</h2>
|
||||||
|
<Separator />
|
||||||
|
<dl className="flex flex-col gap-5 text-sm">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
|
||||||
|
Asset Type
|
||||||
|
</dt>
|
||||||
|
<dd>{TYPE_LABEL[asset.type]}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
|
||||||
|
Dimensions
|
||||||
|
</dt>
|
||||||
|
<dd>{asset.dimensions}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
|
||||||
|
File Size
|
||||||
|
</dt>
|
||||||
|
<dd>{asset.size}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
|
||||||
|
Tags
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="icon-xs" aria-label="Add tag">
|
||||||
|
<PlusIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||||
|
{asset.tags.map((tag) => (
|
||||||
|
<Badge key={tag}>{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h3 className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
|
||||||
|
Used In
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{asset.usedIn.map((usage) => (
|
||||||
|
<Item key={usage.title} variant="outline">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{usage.title}</ItemTitle>
|
||||||
|
<ItemDescription>{usage.role}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="mt-6 border-t pt-6">
|
||||||
|
<Button className="w-full">
|
||||||
|
<DownloadIcon data-icon="inline-start" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
ImageIcon,
|
||||||
|
SearchIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Badge } from "@/styles/base-sera/ui/badge"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
} from "@/styles/base-sera/ui/card"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@/styles/base-sera/ui/input-group"
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/styles/base-sera/ui/pagination"
|
||||||
|
|
||||||
|
import { ASSETS, type Asset, type AssetType } from "../data"
|
||||||
|
|
||||||
|
function AssetTypeIcon({
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
type: AssetType
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
if (type === "MP4") {
|
||||||
|
return <VideoIcon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "PDF") {
|
||||||
|
return <FileTextIcon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ImageIcon className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetGrid({
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
selectedId: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput placeholder="Search files, tags, or metadata..." />
|
||||||
|
</InputGroup>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{ASSETS.map((asset) => (
|
||||||
|
<AssetGridItem
|
||||||
|
key={asset.id}
|
||||||
|
asset={asset}
|
||||||
|
selected={asset.id === selectedId}
|
||||||
|
onSelect={() => onSelect(asset.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-center">
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious href="#" />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href="#" isActive>
|
||||||
|
1
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href="#">2</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href="#">3</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext href="#" />
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssetGridItem({
|
||||||
|
asset,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
asset: Asset
|
||||||
|
selected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className="group flex flex-col gap-2.5 text-left outline-none focus-visible:[&>div:first-child]:ring-2 focus-visible:[&>div:first-child]:ring-ring"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex aspect-4/3 items-center justify-center bg-muted/60 ring-1 ring-border/70 transition-shadow ring-inset group-hover:ring-foreground/40",
|
||||||
|
selected && "ring-2 ring-foreground group-hover:ring-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
<div className="absolute top-2 left-2 flex size-5 items-center justify-center bg-foreground text-background">
|
||||||
|
<CheckIcon className="size-3" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="absolute top-2 right-2 border bg-background px-2 py-1 text-[0.625rem]"
|
||||||
|
>
|
||||||
|
{asset.type}
|
||||||
|
</Badge>
|
||||||
|
{asset.duration ? (
|
||||||
|
<Badge className="absolute bottom-2 left-2 bg-foreground px-2 py-1 text-background">
|
||||||
|
{asset.duration}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<AssetTypeIcon
|
||||||
|
type={asset.type}
|
||||||
|
className="size-7 text-muted-foreground/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 px-0.5">
|
||||||
|
<p className="line-clamp-1 text-sm font-medium">{asset.name}</p>
|
||||||
|
<p className="text-[0.625rem] font-semibold tracking-wider text-muted-foreground uppercase">
|
||||||
|
{asset.date} · {asset.size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ArrowLeftIcon, SlidersHorizontalIcon, UploadIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
} from "@/styles/base-sera/ui/breadcrumb"
|
||||||
|
import { Button } from "@/styles/base-sera/ui/button"
|
||||||
|
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
|
||||||
|
|
||||||
|
export function PreviewHeader() {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-2 text-center sm:text-left">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
|
||||||
|
<ArrowLeftIcon className="size-3.5" />
|
||||||
|
Asset management
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
|
||||||
|
Media Library
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="bg-background hover:bg-background/80"
|
||||||
|
>
|
||||||
|
<SlidersHorizontalIcon data-icon="inline-start" />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<UploadIcon data-icon="inline-start" />
|
||||||
|
Upload Assets
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
188
apps/v4/app/(app)/(styles)/sera/media-library/data.ts
Normal file
188
apps/v4/app/(app)/(styles)/sera/media-library/data.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
export type AssetType = "JPEG" | "PNG" | "WEBP" | "MP4" | "PDF"
|
||||||
|
|
||||||
|
export type Asset = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
date: string
|
||||||
|
size: string
|
||||||
|
type: AssetType
|
||||||
|
dimensions: string
|
||||||
|
duration?: string
|
||||||
|
uploadedBy: string
|
||||||
|
uploadedByInitials: string
|
||||||
|
uploadedOn: string
|
||||||
|
tags: string[]
|
||||||
|
usedIn: { title: string; role: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ASSETS: Asset[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "brutalism-facade-01.jpg",
|
||||||
|
date: "Oct 24",
|
||||||
|
size: "4.2 MB",
|
||||||
|
type: "JPEG",
|
||||||
|
dimensions: "4000 × 3000",
|
||||||
|
uploadedBy: "Marcus Chen",
|
||||||
|
uploadedByInitials: "MC",
|
||||||
|
uploadedOn: "Oct 24, 2024",
|
||||||
|
tags: ["architecture", "brutalism", "exterior", "summer-issue"],
|
||||||
|
usedIn: [
|
||||||
|
{ title: "Brutalism's Second Act", role: "Cover Image" },
|
||||||
|
{ title: "Autumn Sartorial Code", role: "Inline Gallery" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "brutalism-interior-raw.jpg",
|
||||||
|
date: "Oct 24",
|
||||||
|
size: "3.8 MB",
|
||||||
|
type: "JPEG",
|
||||||
|
dimensions: "3800 × 2850",
|
||||||
|
uploadedBy: "Marcus Chen",
|
||||||
|
uploadedByInitials: "MC",
|
||||||
|
uploadedOn: "Oct 24, 2024",
|
||||||
|
tags: ["architecture", "brutalism", "interior"],
|
||||||
|
usedIn: [{ title: "Brutalism's Second Act", role: "Inline Gallery" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
name: "seattle-living-building-diagram.png",
|
||||||
|
date: "Oct 22",
|
||||||
|
size: "1.1 MB",
|
||||||
|
type: "PNG",
|
||||||
|
dimensions: "2000 × 1500",
|
||||||
|
uploadedBy: "Sarah Jenkins",
|
||||||
|
uploadedByInitials: "SJ",
|
||||||
|
uploadedOn: "Oct 22, 2024",
|
||||||
|
tags: ["diagram", "sustainability", "seattle"],
|
||||||
|
usedIn: [
|
||||||
|
{ title: "The Future of Sustainable Architecture", role: "Diagram" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
name: "interview-sofia-coppola-clip1.mp4",
|
||||||
|
date: "Oct 18",
|
||||||
|
size: "45.0 MB",
|
||||||
|
type: "MP4",
|
||||||
|
dimensions: "1920 × 1080",
|
||||||
|
duration: "0:45",
|
||||||
|
uploadedBy: "Emma Ross",
|
||||||
|
uploadedByInitials: "ER",
|
||||||
|
uploadedOn: "Oct 18, 2024",
|
||||||
|
tags: ["video", "interview", "film"],
|
||||||
|
usedIn: [{ title: "The Aesthetics of Isolation", role: "Featured Video" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
name: "kyoto-kilns-pottery-detail.jpg",
|
||||||
|
date: "Oct 15",
|
||||||
|
size: "5.6 MB",
|
||||||
|
type: "JPEG",
|
||||||
|
dimensions: "4500 × 3000",
|
||||||
|
uploadedBy: "Marcus Chen",
|
||||||
|
uploadedByInitials: "MC",
|
||||||
|
uploadedOn: "Oct 15, 2024",
|
||||||
|
tags: ["ceramics", "kyoto", "craft"],
|
||||||
|
usedIn: [{ title: "Kyoto's Oldest Kilns", role: "Hero Image" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6",
|
||||||
|
name: "copenhagen-design-week-street.jpg",
|
||||||
|
date: "Oct 12",
|
||||||
|
size: "3.2 MB",
|
||||||
|
type: "JPEG",
|
||||||
|
dimensions: "3600 × 2400",
|
||||||
|
uploadedBy: "Noah Bennett",
|
||||||
|
uploadedByInitials: "NB",
|
||||||
|
uploadedOn: "Oct 12, 2024",
|
||||||
|
tags: ["copenhagen", "design-week", "street"],
|
||||||
|
usedIn: [{ title: "Field Notes from Copenhagen", role: "Inline Gallery" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "7",
|
||||||
|
name: "minimalist-chair-render.webp",
|
||||||
|
date: "Oct 10",
|
||||||
|
size: "0.8 MB",
|
||||||
|
type: "WEBP",
|
||||||
|
dimensions: "2400 × 1600",
|
||||||
|
uploadedBy: "Claire Duval",
|
||||||
|
uploadedByInitials: "CD",
|
||||||
|
uploadedOn: "Oct 10, 2024",
|
||||||
|
tags: ["furniture", "minimalism", "render"],
|
||||||
|
usedIn: [{ title: "The New Vanguard", role: "Product Shot" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "8",
|
||||||
|
name: "autumn-issue-style-guide.pdf",
|
||||||
|
date: "Oct 05",
|
||||||
|
size: "12.4 MB",
|
||||||
|
type: "PDF",
|
||||||
|
dimensions: "N/A",
|
||||||
|
uploadedBy: "Emma Ross",
|
||||||
|
uploadedByInitials: "ER",
|
||||||
|
uploadedOn: "Oct 05, 2024",
|
||||||
|
tags: ["guidelines", "internal", "autumn"],
|
||||||
|
usedIn: [{ title: "Autumn Issue 2024", role: "Reference" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9",
|
||||||
|
name: "milan-lighting-studio-visit.jpg",
|
||||||
|
date: "Oct 09",
|
||||||
|
size: "6.1 MB",
|
||||||
|
type: "JPEG",
|
||||||
|
dimensions: "5200 × 3466",
|
||||||
|
uploadedBy: "Claire Duval",
|
||||||
|
uploadedByInitials: "CD",
|
||||||
|
uploadedOn: "Oct 09, 2024",
|
||||||
|
tags: ["milan", "lighting", "studio"],
|
||||||
|
usedIn: [
|
||||||
|
{ title: "Milan's Most Elusive Lighting Designer", role: "Hero Image" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "10",
|
||||||
|
name: "lisbon-culinary-scene-raw.webp",
|
||||||
|
date: "Oct 14",
|
||||||
|
size: "2.4 MB",
|
||||||
|
type: "WEBP",
|
||||||
|
dimensions: "3000 × 2000",
|
||||||
|
uploadedBy: "Amara Iqbal",
|
||||||
|
uploadedByInitials: "AI",
|
||||||
|
uploadedOn: "Oct 14, 2024",
|
||||||
|
tags: ["lisbon", "food", "editorial"],
|
||||||
|
usedIn: [
|
||||||
|
{ title: "Lisbon's Quiet Culinary Renaissance", role: "Inline Gallery" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "11",
|
||||||
|
name: "print-magazine-covers-mo...",
|
||||||
|
date: "Sep 26",
|
||||||
|
size: "8.9 MB",
|
||||||
|
type: "PNG",
|
||||||
|
dimensions: "3200 × 2400",
|
||||||
|
uploadedBy: "Mina Okafor",
|
||||||
|
uploadedByInitials: "MO",
|
||||||
|
uploadedOn: "Sep 26, 2024",
|
||||||
|
tags: ["print", "magazine", "covers"],
|
||||||
|
usedIn: [{ title: "The Return of Print", role: "Cover Image" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "12",
|
||||||
|
name: "avant-garde-furniture-trailer.mp4",
|
||||||
|
date: "Sep 30",
|
||||||
|
size: "78.2 MB",
|
||||||
|
type: "MP4",
|
||||||
|
dimensions: "3840 × 2160",
|
||||||
|
duration: "1:12",
|
||||||
|
uploadedBy: "Tommy Rhodes",
|
||||||
|
uploadedByInitials: "TR",
|
||||||
|
uploadedOn: "Sep 30, 2024",
|
||||||
|
tags: ["video", "furniture", "trailer"],
|
||||||
|
usedIn: [
|
||||||
|
{ title: "Collecting the New Avant-Garde", role: "Featured Video" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
30
apps/v4/app/(app)/(styles)/sera/media-library/index.tsx
Normal file
30
apps/v4/app/(app)/(styles)/sera/media-library/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { Separator } from "@/styles/base-sera/ui/separator"
|
||||||
|
|
||||||
|
import { AssetDetails } from "./components/asset-details"
|
||||||
|
import { AssetGrid } from "./components/asset-grid"
|
||||||
|
import { PreviewHeader } from "./components/preview-header"
|
||||||
|
import { ASSETS } from "./data"
|
||||||
|
|
||||||
|
export function MediaLibrary() {
|
||||||
|
const [selectedId, setSelectedId] = React.useState<string>(ASSETS[0].id)
|
||||||
|
|
||||||
|
const selectedAsset = React.useMemo(
|
||||||
|
() => ASSETS.find((asset) => asset.id === selectedId) ?? ASSETS[0],
|
||||||
|
[selectedId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
|
||||||
|
<PreviewHeader />
|
||||||
|
<Separator className="hidden sm:block" />
|
||||||
|
<div className="container grid grid-cols-1 items-start gap-(--gap) py-(--gap) xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<AssetGrid selectedId={selectedId} onSelect={setSelectedId} />
|
||||||
|
<AssetDetails asset={selectedAsset} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
apps/v4/app/(app)/(styles)/sera/opengraph-image.jpg
Normal file
BIN
apps/v4/app/(app)/(styles)/sera/opengraph-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
72
apps/v4/app/(app)/(styles)/sera/page.tsx
Normal file
72
apps/v4/app/(app)/(styles)/sera/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { type Metadata } from "next"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
import {
|
||||||
|
PageActions,
|
||||||
|
PageHeader,
|
||||||
|
PageHeaderDescription,
|
||||||
|
PageHeaderHeading,
|
||||||
|
} from "@/components/page-header"
|
||||||
|
import { Button } from "@/styles/radix-sera/ui/button"
|
||||||
|
|
||||||
|
import { AudienceAnalytics } from "./audience-analytics"
|
||||||
|
import { LazyPreview } from "./components/lazy-preview"
|
||||||
|
|
||||||
|
import "./style.css"
|
||||||
|
|
||||||
|
import { ArrowRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { ImagePreview } from "./components/image-preview"
|
||||||
|
|
||||||
|
const title = "Introducing Sera"
|
||||||
|
const description =
|
||||||
|
"Minimal. Editorial. Typographic. Underline Controls and Uppercase Headings. Shaped by Print Design Principles."
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SeraPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader>
|
||||||
|
<PageHeaderHeading className="font-(family-name:--font-playfair-display) text-[2.875rem] tracking-tight!">
|
||||||
|
{title}
|
||||||
|
</PageHeaderHeading>
|
||||||
|
<PageHeaderDescription className="max-w-2xl text-pretty md:text-balance">
|
||||||
|
{description}
|
||||||
|
</PageHeaderDescription>
|
||||||
|
<PageActions className="**:[.container]:justify-start">
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link href="/create?preset=b4xFeBLg4O">
|
||||||
|
Open in shadcn/create
|
||||||
|
<ArrowRightIcon data-icon="inline-end" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</PageActions>
|
||||||
|
</PageHeader>
|
||||||
|
<ImagePreview />
|
||||||
|
<div className="container-wrapper hidden flex-1 flex-col section-soft px-0 md:flex md:px-2 md:py-12">
|
||||||
|
<div className="container flex flex-1 flex-col gap-10 px-0 3xl:max-w-[2000px] md:px-6">
|
||||||
|
<AudienceAnalytics />
|
||||||
|
<LazyPreview name="articleDirectory" />
|
||||||
|
<LazyPreview name="emptyState" />
|
||||||
|
<LazyPreview name="editArticle" />
|
||||||
|
<LazyPreview name="mediaLibrary" />
|
||||||
|
<LazyPreview name="mediaLibraryTable" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* <ThemeSwitcher /> */}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
495
apps/v4/app/(app)/(styles)/sera/style.css
Normal file
495
apps/v4/app/(app)/(styles)/sera/style.css
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
@layer base {
|
||||||
|
.preview {
|
||||||
|
--font-sans: var(--font-noto-sans);
|
||||||
|
--font-heading: var(--font-playfair-display);
|
||||||
|
contain-intrinsic-size: auto 900px;
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-taupe {
|
||||||
|
--radius: 0;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.147 0.004 49.3);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.147 0.004 49.3);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.147 0.004 49.3);
|
||||||
|
--primary: oklch(0.214 0.009 43.1);
|
||||||
|
--primary-foreground: oklch(0.986 0.002 67.8);
|
||||||
|
--secondary: oklch(0.96 0.002 17.2);
|
||||||
|
--secondary-foreground: oklch(0.214 0.009 43.1);
|
||||||
|
--muted: oklch(0.96 0.002 17.2);
|
||||||
|
--muted-foreground: oklch(0.547 0.021 43.1);
|
||||||
|
--accent: oklch(0.96 0.002 17.2);
|
||||||
|
--accent-foreground: oklch(0.214 0.009 43.1);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0.005 34.3);
|
||||||
|
--input: oklch(0.922 0.005 34.3);
|
||||||
|
--ring: oklch(0.714 0.014 41.2);
|
||||||
|
--chart-1: oklch(0.868 0.007 39.5);
|
||||||
|
--chart-2: oklch(0.547 0.021 43.1);
|
||||||
|
--chart-3: oklch(0.438 0.017 39.3);
|
||||||
|
--chart-4: oklch(0.367 0.016 35.7);
|
||||||
|
--chart-5: oklch(0.268 0.011 36.5);
|
||||||
|
--sidebar: oklch(0.986 0.002 67.8);
|
||||||
|
--sidebar-foreground: oklch(0.147 0.004 49.3);
|
||||||
|
--sidebar-primary: oklch(0.214 0.009 43.1);
|
||||||
|
--sidebar-primary-foreground: oklch(0.986 0.002 67.8);
|
||||||
|
--sidebar-accent: oklch(0.96 0.002 17.2);
|
||||||
|
--sidebar-accent-foreground: oklch(0.214 0.009 43.1);
|
||||||
|
--sidebar-border: oklch(0.922 0.005 34.3);
|
||||||
|
--sidebar-ring: oklch(0.714 0.014 41.2);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
--background: oklch(0.147 0.004 49.3);
|
||||||
|
--foreground: oklch(0.986 0.002 67.8);
|
||||||
|
--card: oklch(0.214 0.009 43.1);
|
||||||
|
--card-foreground: oklch(0.986 0.002 67.8);
|
||||||
|
--popover: oklch(0.214 0.009 43.1);
|
||||||
|
--popover-foreground: oklch(0.986 0.002 67.8);
|
||||||
|
--primary: oklch(0.922 0.005 34.3);
|
||||||
|
--primary-foreground: oklch(0.214 0.009 43.1);
|
||||||
|
--secondary: oklch(0.268 0.011 36.5);
|
||||||
|
--secondary-foreground: oklch(0.986 0.002 67.8);
|
||||||
|
--muted: oklch(0.268 0.011 36.5);
|
||||||
|
--muted-foreground: oklch(0.714 0.014 41.2);
|
||||||
|
--accent: oklch(0.268 0.011 36.5);
|
||||||
|
--accent-foreground: oklch(0.986 0.002 67.8);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.547 0.021 43.1);
|
||||||
|
--chart-1: oklch(0.868 0.007 39.5);
|
||||||
|
--chart-2: oklch(0.547 0.021 43.1);
|
||||||
|
--chart-3: oklch(0.438 0.017 39.3);
|
||||||
|
--chart-4: oklch(0.367 0.016 35.7);
|
||||||
|
--chart-5: oklch(0.268 0.011 36.5);
|
||||||
|
--sidebar: oklch(0.214 0.009 43.1);
|
||||||
|
--sidebar-foreground: oklch(0.986 0.002 67.8);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.986 0.002 67.8);
|
||||||
|
--sidebar-accent: oklch(0.268 0.011 36.5);
|
||||||
|
--sidebar-accent-foreground: oklch(0.986 0.002 67.8);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.547 0.021 43.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-neutral {
|
||||||
|
--radius: 0;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-stone {
|
||||||
|
--radius: 0;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--primary: oklch(0.216 0.006 56.043);
|
||||||
|
--primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--secondary: oklch(0.97 0.001 106.424);
|
||||||
|
--secondary-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--muted: oklch(0.97 0.001 106.424);
|
||||||
|
--muted-foreground: oklch(0.553 0.013 58.071);
|
||||||
|
--accent: oklch(0.97 0.001 106.424);
|
||||||
|
--accent-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.923 0.003 48.717);
|
||||||
|
--input: oklch(0.923 0.003 48.717);
|
||||||
|
--ring: oklch(0.709 0.01 56.259);
|
||||||
|
--chart-1: oklch(0.869 0.005 56.366);
|
||||||
|
--chart-2: oklch(0.553 0.013 58.071);
|
||||||
|
--chart-3: oklch(0.444 0.011 73.639);
|
||||||
|
--chart-4: oklch(0.374 0.01 67.558);
|
||||||
|
--chart-5: oklch(0.268 0.007 34.298);
|
||||||
|
--sidebar: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
||||||
|
--sidebar-primary: oklch(0.216 0.006 56.043);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-accent: oklch(0.97 0.001 106.424);
|
||||||
|
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--sidebar-border: oklch(0.923 0.003 48.717);
|
||||||
|
--sidebar-ring: oklch(0.709 0.01 56.259);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
--background: oklch(0.147 0.004 49.25);
|
||||||
|
--foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--card: oklch(0.216 0.006 56.043);
|
||||||
|
--card-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--popover: oklch(0.216 0.006 56.043);
|
||||||
|
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--primary: oklch(0.923 0.003 48.717);
|
||||||
|
--primary-foreground: oklch(0.216 0.006 56.043);
|
||||||
|
--secondary: oklch(0.268 0.007 34.298);
|
||||||
|
--secondary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--muted: oklch(0.268 0.007 34.298);
|
||||||
|
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||||
|
--accent: oklch(0.268 0.007 34.298);
|
||||||
|
--accent-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.553 0.013 58.071);
|
||||||
|
--chart-1: oklch(0.869 0.005 56.366);
|
||||||
|
--chart-2: oklch(0.553 0.013 58.071);
|
||||||
|
--chart-3: oklch(0.444 0.011 73.639);
|
||||||
|
--chart-4: oklch(0.374 0.01 67.558);
|
||||||
|
--chart-5: oklch(0.268 0.007 34.298);
|
||||||
|
--sidebar: oklch(0.216 0.006 56.043);
|
||||||
|
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-accent: oklch(0.268 0.007 34.298);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.553 0.013 58.071);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-zinc {
|
||||||
|
--radius: 0;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.21 0.006 285.885);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
|
--chart-1: oklch(0.871 0.006 286.286);
|
||||||
|
--chart-2: oklch(0.552 0.016 285.938);
|
||||||
|
--chart-3: oklch(0.442 0.017 285.786);
|
||||||
|
--chart-4: oklch(0.37 0.013 285.805);
|
||||||
|
--chart-5: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.92 0.004 286.32);
|
||||||
|
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
|
--chart-1: oklch(0.871 0.006 286.286);
|
||||||
|
--chart-2: oklch(0.552 0.016 285.938);
|
||||||
|
--chart-3: oklch(0.442 0.017 285.786);
|
||||||
|
--chart-4: oklch(0.37 0.013 285.805);
|
||||||
|
--chart-5: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-mauve {
|
||||||
|
--radius: 0;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0.008 326);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0.008 326);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0.008 326);
|
||||||
|
--primary: oklch(0.212 0.019 322.12);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.96 0.003 325.6);
|
||||||
|
--secondary-foreground: oklch(0.212 0.019 322.12);
|
||||||
|
--muted: oklch(0.96 0.003 325.6);
|
||||||
|
--muted-foreground: oklch(0.542 0.034 322.5);
|
||||||
|
--accent: oklch(0.96 0.003 325.6);
|
||||||
|
--accent-foreground: oklch(0.212 0.019 322.12);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0.005 325.62);
|
||||||
|
--input: oklch(0.922 0.005 325.62);
|
||||||
|
--ring: oklch(0.711 0.019 323.02);
|
||||||
|
--chart-1: oklch(0.865 0.012 325.68);
|
||||||
|
--chart-2: oklch(0.542 0.034 322.5);
|
||||||
|
--chart-3: oklch(0.435 0.029 321.78);
|
||||||
|
--chart-4: oklch(0.364 0.029 323.89);
|
||||||
|
--chart-5: oklch(0.263 0.024 320.12);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0.008 326);
|
||||||
|
--sidebar-primary: oklch(0.212 0.019 322.12);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.96 0.003 325.6);
|
||||||
|
--sidebar-accent-foreground: oklch(0.212 0.019 322.12);
|
||||||
|
--sidebar-border: oklch(0.922 0.005 325.62);
|
||||||
|
--sidebar-ring: oklch(0.711 0.019 323.02);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
--background: oklch(0.145 0.008 326);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.212 0.019 322.12);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.212 0.019 322.12);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0.005 325.62);
|
||||||
|
--primary-foreground: oklch(0.212 0.019 322.12);
|
||||||
|
--secondary: oklch(0.263 0.024 320.12);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.263 0.024 320.12);
|
||||||
|
--muted-foreground: oklch(0.711 0.019 323.02);
|
||||||
|
--accent: oklch(0.263 0.024 320.12);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.542 0.034 322.5);
|
||||||
|
--chart-1: oklch(0.865 0.012 325.68);
|
||||||
|
--chart-2: oklch(0.542 0.034 322.5);
|
||||||
|
--chart-3: oklch(0.435 0.029 321.78);
|
||||||
|
--chart-4: oklch(0.364 0.029 323.89);
|
||||||
|
--chart-5: oklch(0.263 0.024 320.12);
|
||||||
|
--sidebar: oklch(0.212 0.019 322.12);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.263 0.024 320.12);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.542 0.034 322.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-olive {
|
||||||
|
--radius: 0;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.153 0.006 107.1);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.153 0.006 107.1);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.153 0.006 107.1);
|
||||||
|
--primary: oklch(0.228 0.013 107.4);
|
||||||
|
--primary-foreground: oklch(0.988 0.003 106.5);
|
||||||
|
--secondary: oklch(0.966 0.005 106.5);
|
||||||
|
--secondary-foreground: oklch(0.228 0.013 107.4);
|
||||||
|
--muted: oklch(0.966 0.005 106.5);
|
||||||
|
--muted-foreground: oklch(0.58 0.031 107.3);
|
||||||
|
--accent: oklch(0.966 0.005 106.5);
|
||||||
|
--accent-foreground: oklch(0.228 0.013 107.4);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.93 0.007 106.5);
|
||||||
|
--input: oklch(0.93 0.007 106.5);
|
||||||
|
--ring: oklch(0.737 0.021 106.9);
|
||||||
|
--chart-1: oklch(0.88 0.011 106.6);
|
||||||
|
--chart-2: oklch(0.58 0.031 107.3);
|
||||||
|
--chart-3: oklch(0.466 0.025 107.3);
|
||||||
|
--chart-4: oklch(0.394 0.023 107.4);
|
||||||
|
--chart-5: oklch(0.286 0.016 107.4);
|
||||||
|
--sidebar: oklch(0.988 0.003 106.5);
|
||||||
|
--sidebar-foreground: oklch(0.153 0.006 107.1);
|
||||||
|
--sidebar-primary: oklch(0.228 0.013 107.4);
|
||||||
|
--sidebar-primary-foreground: oklch(0.988 0.003 106.5);
|
||||||
|
--sidebar-accent: oklch(0.966 0.005 106.5);
|
||||||
|
--sidebar-accent-foreground: oklch(0.228 0.013 107.4);
|
||||||
|
--sidebar-border: oklch(0.93 0.007 106.5);
|
||||||
|
--sidebar-ring: oklch(0.737 0.021 106.9);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
--background: oklch(0.153 0.006 107.1);
|
||||||
|
--foreground: oklch(0.988 0.003 106.5);
|
||||||
|
--card: oklch(0.228 0.013 107.4);
|
||||||
|
--card-foreground: oklch(0.988 0.003 106.5);
|
||||||
|
--popover: oklch(0.228 0.013 107.4);
|
||||||
|
--popover-foreground: oklch(0.988 0.003 106.5);
|
||||||
|
--primary: oklch(0.93 0.007 106.5);
|
||||||
|
--primary-foreground: oklch(0.228 0.013 107.4);
|
||||||
|
--secondary: oklch(0.286 0.016 107.4);
|
||||||
|
--secondary-foreground: oklch(0.988 0.003 106.5);
|
||||||
|
--muted: oklch(0.286 0.016 107.4);
|
||||||
|
--muted-foreground: oklch(0.737 0.021 106.9);
|
||||||
|
--accent: oklch(0.286 0.016 107.4);
|
||||||
|
--accent-foreground: oklch(0.988 0.003 106.5);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.58 0.031 107.3);
|
||||||
|
--chart-1: oklch(0.88 0.011 106.6);
|
||||||
|
--chart-2: oklch(0.58 0.031 107.3);
|
||||||
|
--chart-3: oklch(0.466 0.025 107.3);
|
||||||
|
--chart-4: oklch(0.394 0.023 107.4);
|
||||||
|
--chart-5: oklch(0.286 0.016 107.4);
|
||||||
|
--sidebar: oklch(0.228 0.013 107.4);
|
||||||
|
--sidebar-foreground: oklch(0.988 0.003 106.5);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.988 0.003 106.5);
|
||||||
|
--sidebar-accent: oklch(0.286 0.016 107.4);
|
||||||
|
--sidebar-accent-foreground: oklch(0.988 0.003 106.5);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.58 0.031 107.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-mist {
|
||||||
|
--radius: 0;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.148 0.004 228.8);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.148 0.004 228.8);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.148 0.004 228.8);
|
||||||
|
--primary: oklch(0.218 0.008 223.9);
|
||||||
|
--primary-foreground: oklch(0.987 0.002 197.1);
|
||||||
|
--secondary: oklch(0.963 0.002 197.1);
|
||||||
|
--secondary-foreground: oklch(0.218 0.008 223.9);
|
||||||
|
--muted: oklch(0.963 0.002 197.1);
|
||||||
|
--muted-foreground: oklch(0.56 0.021 213.5);
|
||||||
|
--accent: oklch(0.963 0.002 197.1);
|
||||||
|
--accent-foreground: oklch(0.218 0.008 223.9);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.925 0.005 214.3);
|
||||||
|
--input: oklch(0.925 0.005 214.3);
|
||||||
|
--ring: oklch(0.723 0.014 214.4);
|
||||||
|
--chart-1: oklch(0.872 0.007 219.6);
|
||||||
|
--chart-2: oklch(0.56 0.021 213.5);
|
||||||
|
--chart-3: oklch(0.45 0.017 213.2);
|
||||||
|
--chart-4: oklch(0.378 0.015 216);
|
||||||
|
--chart-5: oklch(0.275 0.011 216.9);
|
||||||
|
--sidebar: oklch(0.987 0.002 197.1);
|
||||||
|
--sidebar-foreground: oklch(0.148 0.004 228.8);
|
||||||
|
--sidebar-primary: oklch(0.218 0.008 223.9);
|
||||||
|
--sidebar-primary-foreground: oklch(0.987 0.002 197.1);
|
||||||
|
--sidebar-accent: oklch(0.963 0.002 197.1);
|
||||||
|
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
|
||||||
|
--sidebar-border: oklch(0.925 0.005 214.3);
|
||||||
|
--sidebar-ring: oklch(0.723 0.014 214.4);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
--background: oklch(0.148 0.004 228.8);
|
||||||
|
--foreground: oklch(0.987 0.002 197.1);
|
||||||
|
--card: oklch(0.218 0.008 223.9);
|
||||||
|
--card-foreground: oklch(0.987 0.002 197.1);
|
||||||
|
--popover: oklch(0.218 0.008 223.9);
|
||||||
|
--popover-foreground: oklch(0.987 0.002 197.1);
|
||||||
|
--primary: oklch(0.925 0.005 214.3);
|
||||||
|
--primary-foreground: oklch(0.218 0.008 223.9);
|
||||||
|
--secondary: oklch(0.275 0.011 216.9);
|
||||||
|
--secondary-foreground: oklch(0.987 0.002 197.1);
|
||||||
|
--muted: oklch(0.275 0.011 216.9);
|
||||||
|
--muted-foreground: oklch(0.723 0.014 214.4);
|
||||||
|
--accent: oklch(0.275 0.011 216.9);
|
||||||
|
--accent-foreground: oklch(0.987 0.002 197.1);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.56 0.021 213.5);
|
||||||
|
--chart-1: oklch(0.872 0.007 219.6);
|
||||||
|
--chart-2: oklch(0.56 0.021 213.5);
|
||||||
|
--chart-3: oklch(0.45 0.017 213.2);
|
||||||
|
--chart-4: oklch(0.378 0.015 216);
|
||||||
|
--chart-5: oklch(0.275 0.011 216.9);
|
||||||
|
--sidebar: oklch(0.218 0.008 223.9);
|
||||||
|
--sidebar-foreground: oklch(0.987 0.002 197.1);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.987 0.002 197.1);
|
||||||
|
--sidebar-accent: oklch(0.275 0.011 216.9);
|
||||||
|
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.56 0.021 213.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility font-heading {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
}
|
||||||
BIN
apps/v4/app/(app)/(styles)/sera/twitter-image.jpg
Normal file
BIN
apps/v4/app/(app)/(styles)/sera/twitter-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -56,7 +56,7 @@ export default function BlocksLayout({
|
|||||||
<a href="#blocks">Browse Blocks</a>
|
<a href="#blocks">Browse Blocks</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link href="/docs/blocks">Add a block</Link>
|
<Link href="/docs/components">View Components</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</PageActions>
|
</PageActions>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|||||||
@@ -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/(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/(create)/components/picker"
|
} from "@/app/(app)/create/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
export function MenuAccentPicker({
|
export function MenuAccentPicker({
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
import { type RegistryItem } from "shadcn/schema"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -9,10 +11,8 @@ import {
|
|||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@/examples/base/ui/command"
|
} from "@/styles/base-nova/ui/command"
|
||||||
import { type RegistryItem } from "shadcn/schema"
|
import { useActionMenu } from "@/app/(app)/create/hooks/use-action-menu"
|
||||||
|
|
||||||
import { useActionMenu } from "@/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/(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/(create)/components/picker"
|
} from "@/app/(app)/create/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/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/(create)/components/picker"
|
} from "@/app/(app)/create/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
export function BasePicker({
|
export function BasePicker({
|
||||||
isMobile,
|
isMobile,
|
||||||
136
apps/v4/app/(app)/create/components/chart-color-picker.tsx
Normal file
136
apps/v4/app/(app)/create/components/chart-color-picker.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { useMounted } from "@/hooks/use-mounted"
|
||||||
|
import {
|
||||||
|
BASE_COLORS,
|
||||||
|
getThemesForBaseColor,
|
||||||
|
type ChartColorName,
|
||||||
|
} from "@/registry/config"
|
||||||
|
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
||||||
|
import {
|
||||||
|
Picker,
|
||||||
|
PickerContent,
|
||||||
|
PickerGroup,
|
||||||
|
PickerRadioGroup,
|
||||||
|
PickerRadioItem,
|
||||||
|
PickerSeparator,
|
||||||
|
PickerTrigger,
|
||||||
|
} from "@/app/(app)/create/components/picker"
|
||||||
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
|
export function ChartColorPicker({
|
||||||
|
isMobile,
|
||||||
|
anchorRef,
|
||||||
|
}: {
|
||||||
|
isMobile: boolean
|
||||||
|
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
}) {
|
||||||
|
const mounted = useMounted()
|
||||||
|
const [params, setParams] = useDesignSystemSearchParams()
|
||||||
|
|
||||||
|
const availableChartColors = React.useMemo(
|
||||||
|
() => getThemesForBaseColor(params.baseColor),
|
||||||
|
[params.baseColor]
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentChartColor = React.useMemo(
|
||||||
|
() =>
|
||||||
|
availableChartColors.find((theme) => theme.name === params.chartColor),
|
||||||
|
[availableChartColors, params.chartColor]
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentChartColorIsBaseColor = React.useMemo(
|
||||||
|
() => BASE_COLORS.find((baseColor) => baseColor.name === params.chartColor),
|
||||||
|
[params.chartColor]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentChartColor && availableChartColors.length > 0) {
|
||||||
|
setParams({ chartColor: availableChartColors[0].name })
|
||||||
|
}
|
||||||
|
}, [currentChartColor, availableChartColors, setParams])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group/picker relative">
|
||||||
|
<Picker>
|
||||||
|
<PickerTrigger>
|
||||||
|
<div className="flex flex-col justify-start text-left">
|
||||||
|
<div className="text-xs text-muted-foreground">Chart Color</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
{currentChartColor?.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mounted && (
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color":
|
||||||
|
currentChartColor?.cssVars?.dark?.[
|
||||||
|
currentChartColorIsBaseColor
|
||||||
|
? "muted-foreground"
|
||||||
|
: "primary"
|
||||||
|
],
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 rounded-full bg-(--color) select-none md:right-2.5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PickerTrigger>
|
||||||
|
<PickerContent
|
||||||
|
anchor={isMobile ? anchorRef : undefined}
|
||||||
|
side={isMobile ? "top" : "right"}
|
||||||
|
align={isMobile ? "center" : "start"}
|
||||||
|
className="max-h-92"
|
||||||
|
>
|
||||||
|
<PickerRadioGroup
|
||||||
|
value={currentChartColor?.name}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setParams({ chartColor: value as ChartColorName })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PickerGroup>
|
||||||
|
{availableChartColors
|
||||||
|
.filter((theme) =>
|
||||||
|
BASE_COLORS.find((baseColor) => baseColor.name === theme.name)
|
||||||
|
)
|
||||||
|
.map((theme) => (
|
||||||
|
<PickerRadioItem
|
||||||
|
key={theme.name}
|
||||||
|
value={theme.name}
|
||||||
|
closeOnClick={isMobile}
|
||||||
|
>
|
||||||
|
{theme.title}
|
||||||
|
</PickerRadioItem>
|
||||||
|
))}
|
||||||
|
</PickerGroup>
|
||||||
|
<PickerSeparator />
|
||||||
|
<PickerGroup>
|
||||||
|
{availableChartColors
|
||||||
|
.filter(
|
||||||
|
(theme) =>
|
||||||
|
!BASE_COLORS.find(
|
||||||
|
(baseColor) => baseColor.name === theme.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((theme) => (
|
||||||
|
<PickerRadioItem
|
||||||
|
key={theme.name}
|
||||||
|
value={theme.name}
|
||||||
|
closeOnClick={isMobile}
|
||||||
|
>
|
||||||
|
{theme.title}
|
||||||
|
</PickerRadioItem>
|
||||||
|
))}
|
||||||
|
</PickerGroup>
|
||||||
|
</PickerRadioGroup>
|
||||||
|
</PickerContent>
|
||||||
|
</Picker>
|
||||||
|
<LockButton
|
||||||
|
param="chartColor"
|
||||||
|
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Button } from "@/examples/base/ui/button"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||||
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
|
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
|
||||||
|
|
||||||
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
||||||
const presetCode = usePresetCode()
|
const presetCode = usePresetCode()
|
||||||
const [hasCopied, setHasCopied] = React.useState(false)
|
const [hasCopied, setHasCopied] = React.useState(false)
|
||||||
|
const label = hasCopied ? "Copied" : `--preset ${presetCode}`
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (hasCopied) {
|
if (hasCopied) {
|
||||||
@@ -32,12 +33,13 @@ export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
|
title={label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{hasCopied ? "Copied" : `--preset ${presetCode}`}</span>
|
<span className="block min-w-0 truncate">{label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
119
apps/v4/app/(app)/create/components/customizer.tsx
Normal file
119
apps/v4/app/(app)/create/components/customizer.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
|
import { type RegistryItem } from "shadcn/schema"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { getThemesForBaseColor, STYLES } from "@/registry/config"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
} from "@/styles/base-nova/ui/card"
|
||||||
|
import { FieldGroup, FieldSeparator } from "@/styles/base-nova/ui/field"
|
||||||
|
import { MenuAccentPicker } from "@/app/(app)/create/components/accent-picker"
|
||||||
|
import { ActionMenu } from "@/app/(app)/create/components/action-menu"
|
||||||
|
import { BaseColorPicker } from "@/app/(app)/create/components/base-color-picker"
|
||||||
|
import { BasePicker } from "@/app/(app)/create/components/base-picker"
|
||||||
|
import { ChartColorPicker } from "@/app/(app)/create/components/chart-color-picker"
|
||||||
|
import { CopyPreset } from "@/app/(app)/create/components/copy-preset"
|
||||||
|
import { FontPicker } from "@/app/(app)/create/components/font-picker"
|
||||||
|
import { IconLibraryPicker } from "@/app/(app)/create/components/icon-library-picker"
|
||||||
|
import { MainMenu } from "@/app/(app)/create/components/main-menu"
|
||||||
|
import { MenuColorPicker } from "@/app/(app)/create/components/menu-picker"
|
||||||
|
import { OpenPreset } from "@/app/(app)/create/components/open-preset"
|
||||||
|
import { RadiusPicker } from "@/app/(app)/create/components/radius-picker"
|
||||||
|
import { RandomButton } from "@/app/(app)/create/components/random-button"
|
||||||
|
import { ResetDialog } from "@/app/(app)/create/components/reset-button"
|
||||||
|
import { StylePicker } from "@/app/(app)/create/components/style-picker"
|
||||||
|
import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
|
||||||
|
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
|
||||||
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
|
// Only visible when user clicks "Create Project".
|
||||||
|
const ProjectForm = dynamic(() =>
|
||||||
|
import("@/app/(app)/create/components/project-form").then(
|
||||||
|
(m) => m.ProjectForm
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export function Customizer({
|
||||||
|
itemsByBase,
|
||||||
|
}: {
|
||||||
|
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
|
||||||
|
}) {
|
||||||
|
const [params] = useDesignSystemSearchParams()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const anchorRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const availableThemes = React.useMemo(
|
||||||
|
() => getThemesForBaseColor(params.baseColor),
|
||||||
|
[params.baseColor]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="dark top-24 right-12 isolate z-10 max-h-full min-h-0 w-full self-start rounded-2xl bg-card/90 shadow-xl backdrop-blur-xl md:w-(--customizer-width)"
|
||||||
|
ref={anchorRef}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<CardHeader className="hidden items-center justify-between gap-2 border-b group-data-reversed/layout:flex-row-reverse md:flex">
|
||||||
|
<MainMenu />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="no-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden md:overflow-y-auto">
|
||||||
|
<FieldGroup className="flex-row gap-2.5 py-px **:data-[slot=field-separator]:-mx-4 **:data-[slot=field-separator]:w-auto md:flex-col md:gap-3.25">
|
||||||
|
<StylePicker
|
||||||
|
styles={STYLES}
|
||||||
|
isMobile={isMobile}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
/>
|
||||||
|
<FieldSeparator className="hidden md:block" />
|
||||||
|
<BaseColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||||
|
<ThemePicker
|
||||||
|
themes={availableThemes}
|
||||||
|
isMobile={isMobile}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
/>
|
||||||
|
<ChartColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||||
|
<FieldSeparator className="hidden md:block" />
|
||||||
|
<FontPicker
|
||||||
|
label="Heading"
|
||||||
|
param="fontHeading"
|
||||||
|
fonts={FONT_HEADING_OPTIONS}
|
||||||
|
isMobile={isMobile}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
/>
|
||||||
|
<FontPicker
|
||||||
|
label="Font"
|
||||||
|
param="font"
|
||||||
|
fonts={FONTS}
|
||||||
|
isMobile={isMobile}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
/>
|
||||||
|
<FieldSeparator className="hidden md:block" />
|
||||||
|
<IconLibraryPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||||
|
<RadiusPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||||
|
<FieldSeparator className="hidden md:block" />
|
||||||
|
<MenuColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||||
|
<MenuAccentPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||||
|
{isMobile && <BasePicker isMobile={isMobile} anchorRef={anchorRef} />}
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
|
||||||
|
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
|
||||||
|
<OpenPreset
|
||||||
|
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
|
||||||
|
label={isMobile ? "Open" : "Open Preset"}
|
||||||
|
/>
|
||||||
|
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
|
||||||
|
<ActionMenu itemsByBase={itemsByBase} />
|
||||||
|
<ResetDialog />
|
||||||
|
</CardFooter>
|
||||||
|
<CardFooter className="-mt-3 hidden min-w-0 gap-2 md:flex md:flex-col md:**:[button,a]:w-full">
|
||||||
|
<ProjectForm />
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,17 +5,24 @@ import * as React from "react"
|
|||||||
import {
|
import {
|
||||||
buildRegistryTheme,
|
buildRegistryTheme,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
|
POINTER_CURSOR_SELECTOR,
|
||||||
type DesignSystemConfig,
|
type DesignSystemConfig,
|
||||||
} from "@/registry/config"
|
} from "@/registry/config"
|
||||||
import { useIframeMessageListener } from "@/app/(create)/hooks/use-iframe-sync"
|
import { useIframeMessageListener } from "@/app/(app)/create/hooks/use-iframe-sync"
|
||||||
import { FONTS } from "@/app/(create)/lib/fonts"
|
import { FONTS } from "@/app/(app)/create/lib/fonts"
|
||||||
import {
|
import {
|
||||||
useDesignSystemSearchParams,
|
useDesignSystemSearchParams,
|
||||||
type DesignSystemSearchParams,
|
type DesignSystemSearchParams,
|
||||||
} from "@/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
|
||||||
|
const POINTER_CURSOR_CSS = `@layer base {
|
||||||
|
${POINTER_CURSOR_SELECTOR} {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
type RegistryThemeCssVars = NonNullable<
|
type RegistryThemeCssVars = NonNullable<
|
||||||
ReturnType<typeof buildRegistryTheme>["cssVars"]
|
ReturnType<typeof buildRegistryTheme>["cssVars"]
|
||||||
@@ -44,14 +51,17 @@ function buildCssRule(selector: string, cssVars?: Record<string, string>) {
|
|||||||
return `${selector} {\n${declarations}\n}\n`
|
return `${selector} {\n${declarations}\n}\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildThemeCssText(cssVars: RegistryThemeCssVars) {
|
function buildThemeCssText(cssVars: RegistryThemeCssVars, pointer: boolean) {
|
||||||
return [
|
return [
|
||||||
buildCssRule(":root", {
|
buildCssRule(":root", {
|
||||||
...(cssVars.theme ?? {}),
|
...(cssVars.theme ?? {}),
|
||||||
...(cssVars.light ?? {}),
|
...(cssVars.light ?? {}),
|
||||||
}),
|
}),
|
||||||
buildCssRule(".dark", cssVars.dark),
|
buildCssRule(".dark", cssVars.dark),
|
||||||
].join("\n")
|
pointer ? POINTER_CURSOR_CSS : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesignSystemProvider({
|
export function DesignSystemProvider({
|
||||||
@@ -64,18 +74,38 @@ export function DesignSystemProvider({
|
|||||||
history: "replace", // …or push updates into the iframe history.
|
history: "replace", // …or push updates into the iframe history.
|
||||||
})
|
})
|
||||||
const [isReady, setIsReady] = React.useState(false)
|
const [isReady, setIsReady] = React.useState(false)
|
||||||
const { style, theme, font, baseColor, menuAccent, menuColor, radius } =
|
const {
|
||||||
searchParams
|
style,
|
||||||
|
theme,
|
||||||
|
font,
|
||||||
|
fontHeading,
|
||||||
|
baseColor,
|
||||||
|
chartColor,
|
||||||
|
menuAccent,
|
||||||
|
menuColor,
|
||||||
|
pointer,
|
||||||
|
radius,
|
||||||
|
} = searchParams
|
||||||
const effectiveRadius = style === "lyra" ? "none" : radius
|
const effectiveRadius = style === "lyra" ? "none" : radius
|
||||||
const selectedFont = React.useMemo(
|
const selectedFont = React.useMemo(
|
||||||
() => FONTS.find((fontOption) => fontOption.value === font),
|
() => FONTS.find((fontOption) => fontOption.value === font),
|
||||||
[font]
|
[font]
|
||||||
)
|
)
|
||||||
|
const selectedHeadingFont = React.useMemo(() => {
|
||||||
|
if (fontHeading === "inherit" || fontHeading === font) {
|
||||||
|
return selectedFont
|
||||||
|
}
|
||||||
|
|
||||||
|
return FONTS.find((fontOption) => fontOption.value === fontHeading)
|
||||||
|
}, [font, fontHeading, selectedFont])
|
||||||
const initialFontSansRef = React.useRef<string | null>(null)
|
const initialFontSansRef = React.useRef<string | null>(null)
|
||||||
|
const initialFontHeadingRef = React.useRef<string | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
initialFontSansRef.current =
|
initialFontSansRef.current =
|
||||||
document.documentElement.style.getPropertyValue("--font-sans")
|
document.documentElement.style.getPropertyValue("--font-sans")
|
||||||
|
initialFontHeadingRef.current =
|
||||||
|
document.documentElement.style.getPropertyValue("--font-heading")
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
removeManagedBodyClasses(document.body)
|
removeManagedBodyClasses(document.body)
|
||||||
@@ -86,10 +116,18 @@ export function DesignSystemProvider({
|
|||||||
"--font-sans",
|
"--font-sans",
|
||||||
initialFontSansRef.current
|
initialFontSansRef.current
|
||||||
)
|
)
|
||||||
return
|
} else {
|
||||||
|
document.documentElement.style.removeProperty("--font-sans")
|
||||||
}
|
}
|
||||||
|
|
||||||
document.documentElement.style.removeProperty("--font-sans")
|
if (initialFontHeadingRef.current) {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--font-heading",
|
||||||
|
initialFontHeadingRef.current
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
document.documentElement.style.removeProperty("--font-heading")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -103,7 +141,7 @@ export function DesignSystemProvider({
|
|||||||
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
|
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (style === "lyra" && radius !== "none") {
|
if (style === "lyra" || (style === "sera" && radius !== "none")) {
|
||||||
setSearchParams({ radius: "none" })
|
setSearchParams({ radius: "none" })
|
||||||
}
|
}
|
||||||
}, [style, radius, setSearchParams])
|
}, [style, radius, setSearchParams])
|
||||||
@@ -124,12 +162,29 @@ export function DesignSystemProvider({
|
|||||||
// Always set --font-sans for the preview so the selected font is visible.
|
// Always set --font-sans for the preview so the selected font is visible.
|
||||||
// The font type (sans/serif/mono) is metadata for the CLI updater.
|
// The font type (sans/serif/mono) is metadata for the CLI updater.
|
||||||
if (selectedFont) {
|
if (selectedFont) {
|
||||||
const fontFamily = selectedFont.font.style.fontFamily
|
document.documentElement.style.setProperty(
|
||||||
document.documentElement.style.setProperty("--font-sans", fontFamily)
|
"--font-sans",
|
||||||
|
selectedFont.font.style.fontFamily
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedHeadingFont) {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--font-heading",
|
||||||
|
selectedHeadingFont.font.style.fontFamily
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsReady(true)
|
setIsReady(true)
|
||||||
}, [style, theme, font, baseColor, selectedFont])
|
}, [
|
||||||
|
style,
|
||||||
|
theme,
|
||||||
|
font,
|
||||||
|
fontHeading,
|
||||||
|
baseColor,
|
||||||
|
selectedFont,
|
||||||
|
selectedHeadingFont,
|
||||||
|
])
|
||||||
|
|
||||||
const registryTheme = React.useMemo(() => {
|
const registryTheme = React.useMemo(() => {
|
||||||
if (!baseColor || !theme || !menuAccent || !effectiveRadius) {
|
if (!baseColor || !theme || !menuAccent || !effectiveRadius) {
|
||||||
@@ -140,12 +195,13 @@ export function DesignSystemProvider({
|
|||||||
...DEFAULT_CONFIG,
|
...DEFAULT_CONFIG,
|
||||||
baseColor,
|
baseColor,
|
||||||
theme,
|
theme,
|
||||||
|
chartColor,
|
||||||
menuAccent,
|
menuAccent,
|
||||||
radius: effectiveRadius,
|
radius: effectiveRadius,
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildRegistryTheme(config)
|
return buildRegistryTheme(config)
|
||||||
}, [baseColor, theme, menuAccent, effectiveRadius])
|
}, [baseColor, theme, chartColor, menuAccent, effectiveRadius])
|
||||||
|
|
||||||
// Use useLayoutEffect for synchronous CSS var updates.
|
// Use useLayoutEffect for synchronous CSS var updates.
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
@@ -163,8 +219,8 @@ export function DesignSystemProvider({
|
|||||||
document.head.appendChild(styleElement)
|
document.head.appendChild(styleElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
styleElement.textContent = buildThemeCssText(registryTheme.cssVars)
|
styleElement.textContent = buildThemeCssText(registryTheme.cssVars, pointer)
|
||||||
}, [registryTheme])
|
}, [registryTheme, pointer])
|
||||||
|
|
||||||
// Handle menu color inversion by adding/removing dark class to elements with cn-menu-target.
|
// Handle menu color inversion by adding/removing dark class to elements with cn-menu-target.
|
||||||
// useLayoutEffect to apply classes synchronously before paint, avoiding flash.
|
// useLayoutEffect to apply classes synchronously before paint, avoiding flash.
|
||||||
158
apps/v4/app/(app)/create/components/font-picker.tsx
Normal file
158
apps/v4/app/(app)/create/components/font-picker.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { LockButton } from "@/app/(app)/create/components/lock-button"
|
||||||
|
import {
|
||||||
|
Picker,
|
||||||
|
PickerContent,
|
||||||
|
PickerGroup,
|
||||||
|
PickerLabel,
|
||||||
|
PickerRadioGroup,
|
||||||
|
PickerRadioItem,
|
||||||
|
PickerSeparator,
|
||||||
|
PickerTrigger,
|
||||||
|
} from "@/app/(app)/create/components/picker"
|
||||||
|
import { FONTS } from "@/app/(app)/create/lib/fonts"
|
||||||
|
import {
|
||||||
|
useDesignSystemSearchParams,
|
||||||
|
type DesignSystemSearchParams,
|
||||||
|
} from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
|
type FontPickerOption = {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
type: string
|
||||||
|
font: {
|
||||||
|
style: {
|
||||||
|
fontFamily: string
|
||||||
|
}
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FontPicker({
|
||||||
|
label,
|
||||||
|
param,
|
||||||
|
fonts,
|
||||||
|
isMobile,
|
||||||
|
anchorRef,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
param: "font" | "fontHeading"
|
||||||
|
fonts: readonly FontPickerOption[]
|
||||||
|
isMobile: boolean
|
||||||
|
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
}) {
|
||||||
|
const [params, setParams] = useDesignSystemSearchParams()
|
||||||
|
const currentValue = param === "font" ? params.font : params.fontHeading
|
||||||
|
const handleFontChange = React.useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setParams({
|
||||||
|
[param]: value,
|
||||||
|
} as Partial<DesignSystemSearchParams>)
|
||||||
|
},
|
||||||
|
[param, setParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentFont = React.useMemo(
|
||||||
|
() => fonts.find((font) => font.value === currentValue),
|
||||||
|
[fonts, currentValue]
|
||||||
|
)
|
||||||
|
const currentBodyFont = React.useMemo(
|
||||||
|
() => FONTS.find((font) => font.value === params.font),
|
||||||
|
[params.font]
|
||||||
|
)
|
||||||
|
const inheritsBodyFont = param === "fontHeading" && currentValue === "inherit"
|
||||||
|
const displayFontName = inheritsBodyFont
|
||||||
|
? currentBodyFont?.name
|
||||||
|
: currentFont?.name
|
||||||
|
const inheritFontLabel = currentBodyFont ? currentBodyFont.name : "Body font"
|
||||||
|
const groupedFonts = React.useMemo(() => {
|
||||||
|
const pickerFonts =
|
||||||
|
param === "fontHeading"
|
||||||
|
? fonts.filter((font) => font.value !== "inherit")
|
||||||
|
: fonts
|
||||||
|
const groups = new Map<string, FontPickerOption[]>()
|
||||||
|
|
||||||
|
for (const font of pickerFonts) {
|
||||||
|
const existing = groups.get(font.type)
|
||||||
|
if (existing) {
|
||||||
|
existing.push(font)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.set(font.type, [font])
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.entries()).map(([type, items]) => ({
|
||||||
|
type,
|
||||||
|
label: `${type.charAt(0).toUpperCase()}${type.slice(1)}`,
|
||||||
|
items,
|
||||||
|
}))
|
||||||
|
}, [fonts, param])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group/picker relative">
|
||||||
|
<Picker>
|
||||||
|
<PickerTrigger>
|
||||||
|
<div className="flex flex-col justify-start text-left">
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
<div className="line-clamp-1 max-w-[80%] truncate text-sm font-medium text-foreground">
|
||||||
|
{displayFontName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5"
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
currentFont?.font?.style.fontFamily ??
|
||||||
|
currentBodyFont?.font.style.fontFamily,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</div>
|
||||||
|
</PickerTrigger>
|
||||||
|
<PickerContent
|
||||||
|
anchor={isMobile ? anchorRef : undefined}
|
||||||
|
side={isMobile ? "top" : "right"}
|
||||||
|
align={isMobile ? "center" : "start"}
|
||||||
|
className="max-h-96"
|
||||||
|
>
|
||||||
|
<PickerRadioGroup
|
||||||
|
value={currentValue}
|
||||||
|
onValueChange={handleFontChange}
|
||||||
|
>
|
||||||
|
{param === "fontHeading" ? (
|
||||||
|
<>
|
||||||
|
<PickerGroup>
|
||||||
|
<PickerRadioItem value="inherit" closeOnClick={isMobile}>
|
||||||
|
{inheritFontLabel}
|
||||||
|
</PickerRadioItem>
|
||||||
|
</PickerGroup>
|
||||||
|
<PickerSeparator />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{groupedFonts.map((group) => (
|
||||||
|
<PickerGroup key={group.type}>
|
||||||
|
<PickerLabel>{group.label}</PickerLabel>
|
||||||
|
{group.items.map((font) => (
|
||||||
|
<PickerRadioItem
|
||||||
|
key={font.value}
|
||||||
|
value={font.value}
|
||||||
|
closeOnClick={isMobile}
|
||||||
|
>
|
||||||
|
{font.name}
|
||||||
|
</PickerRadioItem>
|
||||||
|
))}
|
||||||
|
</PickerGroup>
|
||||||
|
))}
|
||||||
|
</PickerRadioGroup>
|
||||||
|
</PickerContent>
|
||||||
|
</Picker>
|
||||||
|
<LockButton
|
||||||
|
param={param}
|
||||||
|
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
import { Button } from "@/examples/base/ui/button"
|
|
||||||
import { Redo02Icon, Undo02Icon } from "@hugeicons/core-free-icons"
|
import { Redo02Icon, Undo02Icon } from "@hugeicons/core-free-icons"
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
import { HugeiconsIcon } from "@hugeicons/react"
|
||||||
|
|
||||||
import { useHistory } from "@/app/(create)/hooks/use-history"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
|
import { useHistory } from "@/app/(app)/create/hooks/use-history"
|
||||||
|
|
||||||
export const UNDO_FORWARD_TYPE = "undo-forward"
|
export const 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/(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/(create)/components/picker"
|
} from "@/app/(app)/create/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
const logos = {
|
const logos = {
|
||||||
lucide: (
|
lucide: (
|
||||||
75
apps/v4/app/(app)/create/components/icon-placeholder.tsx
Normal file
75
apps/v4/app/(app)/create/components/icon-placeholder.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { lazy, Suspense } from "react"
|
||||||
|
import { SquareIcon } from "lucide-react"
|
||||||
|
import type { IconLibraryName } from "shadcn/icons"
|
||||||
|
|
||||||
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
|
const IconLucide = lazy(() =>
|
||||||
|
import("@/registry/icons/icon-lucide").then((mod) => ({
|
||||||
|
default: mod.IconLucide,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const IconTabler = lazy(() =>
|
||||||
|
import("@/registry/icons/icon-tabler").then((mod) => ({
|
||||||
|
default: mod.IconTabler,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const IconHugeicons = lazy(() =>
|
||||||
|
import("@/registry/icons/icon-hugeicons").then((mod) => ({
|
||||||
|
default: mod.IconHugeicons,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const IconPhosphor = lazy(() =>
|
||||||
|
import("@/registry/icons/icon-phosphor").then((mod) => ({
|
||||||
|
default: mod.IconPhosphor,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const IconRemixicon = lazy(() =>
|
||||||
|
import("@/registry/icons/icon-remixicon").then((mod) => ({
|
||||||
|
default: mod.IconRemixicon,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preload all icon renderer modules so switching libraries is instant.
|
||||||
|
// These warm the browser module cache; React.lazy resolves immediately
|
||||||
|
// for modules that are already loaded.
|
||||||
|
void import("@/registry/icons/icon-lucide")
|
||||||
|
void import("@/registry/icons/icon-tabler")
|
||||||
|
void import("@/registry/icons/icon-hugeicons")
|
||||||
|
void import("@/registry/icons/icon-phosphor")
|
||||||
|
void import("@/registry/icons/icon-remixicon")
|
||||||
|
|
||||||
|
export function IconPlaceholder({
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
[K in IconLibraryName]: string
|
||||||
|
} & React.ComponentProps<"svg">) {
|
||||||
|
const [{ iconLibrary }] = useDesignSystemSearchParams()
|
||||||
|
const iconName = props[iconLibrary]
|
||||||
|
|
||||||
|
if (!iconName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<SquareIcon {...props} />}>
|
||||||
|
{iconLibrary === "lucide" && <IconLucide name={iconName} {...props} />}
|
||||||
|
{iconLibrary === "tabler" && <IconTabler name={iconName} {...props} />}
|
||||||
|
{iconLibrary === "hugeicons" && (
|
||||||
|
<IconHugeicons name={iconName} {...props} />
|
||||||
|
)}
|
||||||
|
{iconLibrary === "phosphor" && (
|
||||||
|
<IconPhosphor name={iconName} {...props} />
|
||||||
|
)}
|
||||||
|
{iconLibrary === "remixicon" && (
|
||||||
|
<IconRemixicon name={iconName} {...props} />
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,11 +2,16 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { ChevronRightIcon } from "lucide-react"
|
||||||
|
import { type RegistryItem } from "shadcn/schema"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { type Base } from "@/registry/bases"
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/examples/base/ui/collapsible"
|
} from "@/styles/base-nova/ui/collapsible"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -15,14 +20,9 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/examples/base/ui/sidebar"
|
} from "@/styles/base-nova/ui/sidebar"
|
||||||
import { ChevronRightIcon } from "lucide-react"
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
import { type RegistryItem } from "shadcn/schema"
|
import { groupItemsByType } from "@/app/(app)/create/lib/utils"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { type Base } from "@/registry/bases"
|
|
||||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
|
||||||
import { groupItemsByType } from "@/app/(create)/lib/utils"
|
|
||||||
|
|
||||||
const cachedGroupedItems = React.cache(
|
const cachedGroupedItems = React.cache(
|
||||||
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {
|
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {
|
||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
import { HugeiconsIcon } from "@hugeicons/react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useLocks, type LockableParam } from "@/app/(create)/hooks/use-locks"
|
import {
|
||||||
|
useLocks,
|
||||||
|
type LockableParam,
|
||||||
|
} from "@/app/(app)/create/hooks/use-locks"
|
||||||
|
|
||||||
export function LockButton({
|
export function LockButton({
|
||||||
param,
|
param,
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { type Button } from "@/examples/base/ui/button"
|
|
||||||
import { Menu09Icon } from "@hugeicons/core-free-icons"
|
import { Menu09Icon } from "@hugeicons/core-free-icons"
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
import { HugeiconsIcon } from "@hugeicons/react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { type Button } from "@/styles/base-nova/ui/button"
|
||||||
import {
|
import {
|
||||||
Picker,
|
Picker,
|
||||||
PickerContent,
|
PickerContent,
|
||||||
@@ -14,12 +14,13 @@ import {
|
|||||||
PickerSeparator,
|
PickerSeparator,
|
||||||
PickerShortcut,
|
PickerShortcut,
|
||||||
PickerTrigger,
|
PickerTrigger,
|
||||||
} from "@/app/(create)/components/picker"
|
} from "@/app/(app)/create/components/picker"
|
||||||
import { useActionMenuTrigger } from "@/app/(create)/hooks/use-action-menu"
|
import { useActionMenuTrigger } from "@/app/(app)/create/hooks/use-action-menu"
|
||||||
import { useHistory } from "@/app/(create)/hooks/use-history"
|
import { useHistory } from "@/app/(app)/create/hooks/use-history"
|
||||||
import { useRandom } from "@/app/(create)/hooks/use-random"
|
import { useOpenPresetTrigger } from "@/app/(app)/create/hooks/use-open-preset"
|
||||||
import { useReset } from "@/app/(create)/hooks/use-reset"
|
import { useRandom } from "@/app/(app)/create/hooks/use-random"
|
||||||
import { useThemeToggle } from "@/app/(create)/hooks/use-theme-toggle"
|
import { useReset } from "@/app/(app)/create/hooks/use-reset"
|
||||||
|
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
|
||||||
|
|
||||||
const APPLE_PLATFORM_REGEX = /Mac|iPhone|iPad|iPod/
|
const APPLE_PLATFORM_REGEX = /Mac|iPhone|iPad|iPod/
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
|||||||
const [isMac, setIsMac] = React.useState(false)
|
const [isMac, setIsMac] = React.useState(false)
|
||||||
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
|
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
|
||||||
const { openActionMenu } = useActionMenuTrigger()
|
const { openActionMenu } = useActionMenuTrigger()
|
||||||
|
const { openPreset } = useOpenPresetTrigger()
|
||||||
const { randomize } = useRandom()
|
const { randomize } = useRandom()
|
||||||
const { toggleTheme } = useThemeToggle()
|
const { toggleTheme } = useThemeToggle()
|
||||||
const { setShowResetDialog } = useReset()
|
const { setShowResetDialog } = useReset()
|
||||||
@@ -55,6 +57,9 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
|||||||
Navigate...
|
Navigate...
|
||||||
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
|
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
|
||||||
</PickerItem>
|
</PickerItem>
|
||||||
|
<PickerItem onClick={openPreset}>
|
||||||
|
Open Preset... <PickerShortcut>O</PickerShortcut>
|
||||||
|
</PickerItem>
|
||||||
<PickerItem onClick={randomize}>
|
<PickerItem onClick={randomize}>
|
||||||
Shuffle <PickerShortcut>R</PickerShortcut>
|
Shuffle <PickerShortcut>R</PickerShortcut>
|
||||||
</PickerItem>
|
</PickerItem>
|
||||||
@@ -73,7 +78,7 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
|||||||
</PickerItem>
|
</PickerItem>
|
||||||
<PickerSeparator />
|
<PickerSeparator />
|
||||||
<PickerItem onClick={() => setShowResetDialog(true)}>
|
<PickerItem onClick={() => setShowResetDialog(true)}>
|
||||||
Reset
|
Reset <PickerShortcut>⇧R</PickerShortcut>
|
||||||
</PickerItem>
|
</PickerItem>
|
||||||
</PickerGroup>
|
</PickerGroup>
|
||||||
</PickerContent>
|
</PickerContent>
|
||||||
@@ -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/(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/(create)/components/picker"
|
} from "@/app/(app)/create/components/picker"
|
||||||
import {
|
import {
|
||||||
isTranslucentMenuColor,
|
isTranslucentMenuColor,
|
||||||
useDesignSystemSearchParams,
|
useDesignSystemSearchParams,
|
||||||
} from "@/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"
|
||||||
@@ -104,7 +104,7 @@ export function MenuColorPicker({
|
|||||||
<PickerTrigger>
|
<PickerTrigger>
|
||||||
<div className="flex flex-col justify-start text-left">
|
<div className="flex flex-col justify-start text-left">
|
||||||
<div className="text-xs text-muted-foreground">Menu</div>
|
<div className="text-xs text-muted-foreground">Menu</div>
|
||||||
<div className="text-sm font-medium text-foreground">
|
<div className="line-clamp-1 max-w-[80%] truncate text-sm font-medium text-foreground">
|
||||||
{currentMenu?.label}
|
{currentMenu?.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
import { Button } from "@/examples/base/ui/button"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useThemeToggle } from "@/app/(create)/hooks/use-theme-toggle"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
|
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
|
||||||
|
|
||||||
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
|
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
|
||||||
|
|
||||||
200
apps/v4/app/(app)/create/components/open-preset.tsx
Normal file
200
apps/v4/app/(app)/create/components/open-preset.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import Script from "next/script"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/styles/base-nova/ui/dialog"
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "@/styles/base-nova/ui/drawer"
|
||||||
|
import { Field, FieldContent, FieldLabel } from "@/styles/base-nova/ui/field"
|
||||||
|
import { Input } from "@/styles/base-nova/ui/input"
|
||||||
|
import {
|
||||||
|
OPEN_PRESET_FORWARD_TYPE,
|
||||||
|
useOpenPreset,
|
||||||
|
} from "@/app/(app)/create/hooks/use-open-preset"
|
||||||
|
import { parsePresetInput } from "@/app/(app)/create/lib/parse-preset-input"
|
||||||
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
|
const PRESET_EXAMPLE = "b2D0wqNxT"
|
||||||
|
const PRESET_TITLE = "Open Preset"
|
||||||
|
const PRESET_DESCRIPTION = "Paste a preset code to load a saved configuration."
|
||||||
|
|
||||||
|
export function OpenPreset({
|
||||||
|
className,
|
||||||
|
label = "Open Preset",
|
||||||
|
}: React.ComponentProps<typeof Button> & {
|
||||||
|
label?: string
|
||||||
|
}) {
|
||||||
|
const [input, setInput] = React.useState("")
|
||||||
|
const [, setParams] = useDesignSystemSearchParams()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const { open, setOpen } = useOpenPreset()
|
||||||
|
|
||||||
|
const nextPreset = React.useMemo(() => parsePresetInput(input), [input])
|
||||||
|
const isInvalid = input.trim().length > 0 && nextPreset === null
|
||||||
|
|
||||||
|
const handleOpenChange = React.useCallback(
|
||||||
|
(nextOpen: boolean) => {
|
||||||
|
setOpen(nextOpen)
|
||||||
|
|
||||||
|
if (!nextOpen) {
|
||||||
|
setInput("")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmit = React.useCallback(
|
||||||
|
(event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (!nextPreset) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setParams({ preset: nextPreset })
|
||||||
|
handleOpenChange(false)
|
||||||
|
},
|
||||||
|
[handleOpenChange, nextPreset, setParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
const triggerClassName = cn(
|
||||||
|
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
const desktopTrigger = (
|
||||||
|
<Button variant="outline" className={triggerClassName} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const fields = (
|
||||||
|
<Field data-invalid={isInvalid || undefined}>
|
||||||
|
<FieldLabel htmlFor="preset-code" className="sr-only">
|
||||||
|
Preset code
|
||||||
|
</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Input
|
||||||
|
id="preset-code"
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => setInput(event.target.value)}
|
||||||
|
placeholder={`${PRESET_EXAMPLE} or --preset ${PRESET_EXAMPLE}`}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
aria-invalid={isInvalid}
|
||||||
|
className="h-10 md:h-8"
|
||||||
|
/>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button variant="outline" className={triggerClassName}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="dark rounded-t-2xl!">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
|
||||||
|
<DrawerDescription>{PRESET_DESCRIPTION}</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="px-4 py-2">{fields}</div>
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button type="submit" className="h-10" disabled={!nextPreset}>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="outline" type="button" className="h-10">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</form>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
|
||||||
|
<DialogContent className="dark">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{PRESET_TITLE}</DialogTitle>
|
||||||
|
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">{fields}</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose render={<Button variant="outline" type="button" />}>
|
||||||
|
Cancel
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" disabled={!nextPreset}>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenPresetScript() {
|
||||||
|
return (
|
||||||
|
<Script
|
||||||
|
id="open-preset-listener"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function() {
|
||||||
|
// Forward O key.
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
|
if (
|
||||||
|
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
e.target instanceof HTMLSelectElement
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
if (window.parent && window.parent !== window) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: '${OPEN_PRESET_FORWARD_TYPE}',
|
||||||
|
key: e.key
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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/(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} />
|
||||||
@@ -19,7 +19,7 @@ function PickerTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
|
|||||||
<MenuPrimitive.Trigger
|
<MenuPrimitive.Trigger
|
||||||
data-slot="dropdown-menu-trigger"
|
data-slot="dropdown-menu-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-40 shrink-0 touch-manipulation rounded-xl p-3 ring-1 ring-foreground/10 select-none hover:bg-muted focus-visible:ring-foreground/50 focus-visible:outline-none disabled:opacity-50 data-popup-open:bg-muted md:w-full md:rounded-lg md:px-2.5 md:py-2",
|
"relative w-36 shrink-0 touch-manipulation rounded-xl p-3 ring-1 ring-foreground/10 select-none hover:bg-muted focus-visible:ring-foreground/50 focus-visible:outline-none disabled:opacity-50 data-popup-open:bg-muted md:w-full md:rounded-lg md:px-2.5 md:py-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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/(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/(create)/components/picker"
|
} from "@/app/(app)/create/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
export function PresetPicker({
|
export function PresetPicker({
|
||||||
presets,
|
presets,
|
||||||
@@ -31,8 +31,10 @@ export function PresetPicker({
|
|||||||
preset.style === params.style &&
|
preset.style === params.style &&
|
||||||
preset.baseColor === params.baseColor &&
|
preset.baseColor === params.baseColor &&
|
||||||
preset.theme === params.theme &&
|
preset.theme === params.theme &&
|
||||||
|
preset.chartColor === params.chartColor &&
|
||||||
preset.iconLibrary === params.iconLibrary &&
|
preset.iconLibrary === params.iconLibrary &&
|
||||||
preset.font === params.font &&
|
preset.font === params.font &&
|
||||||
|
preset.fontHeading === params.fontHeading &&
|
||||||
preset.menuAccent === params.menuAccent &&
|
preset.menuAccent === params.menuAccent &&
|
||||||
preset.menuColor === params.menuColor &&
|
preset.menuColor === params.menuColor &&
|
||||||
preset.radius === params.radius
|
preset.radius === params.radius
|
||||||
@@ -43,8 +45,10 @@ export function PresetPicker({
|
|||||||
params.style,
|
params.style,
|
||||||
params.baseColor,
|
params.baseColor,
|
||||||
params.theme,
|
params.theme,
|
||||||
|
params.chartColor,
|
||||||
params.iconLibrary,
|
params.iconLibrary,
|
||||||
params.font,
|
params.font,
|
||||||
|
params.fontHeading,
|
||||||
params.menuAccent,
|
params.menuAccent,
|
||||||
params.menuColor,
|
params.menuColor,
|
||||||
params.radius,
|
params.radius,
|
||||||
@@ -67,8 +71,10 @@ export function PresetPicker({
|
|||||||
style: preset.style,
|
style: preset.style,
|
||||||
baseColor: preset.baseColor,
|
baseColor: preset.baseColor,
|
||||||
theme: preset.theme,
|
theme: preset.theme,
|
||||||
|
chartColor: preset.chartColor,
|
||||||
iconLibrary: preset.iconLibrary,
|
iconLibrary: preset.iconLibrary,
|
||||||
font: preset.font,
|
font: preset.font,
|
||||||
|
fontHeading: preset.fontHeading,
|
||||||
menuAccent: preset.menuAccent,
|
menuAccent: preset.menuAccent,
|
||||||
menuColor: preset.menuColor,
|
menuColor: preset.menuColor,
|
||||||
radius: preset.radius,
|
radius: preset.radius,
|
||||||
@@ -106,13 +112,6 @@ export function PresetPicker({
|
|||||||
closeOnClick={isMobile}
|
closeOnClick={isMobile}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{style?.icon && (
|
|
||||||
<div className="flex size-4 shrink-0 items-center justify-center">
|
|
||||||
{React.cloneElement(style.icon, {
|
|
||||||
className: "size-4",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{preset.description}
|
{preset.description}
|
||||||
</div>
|
</div>
|
||||||
</PickerRadioItem>
|
</PickerRadioItem>
|
||||||
37
apps/v4/app/(app)/create/components/preview-switcher.tsx
Normal file
37
apps/v4/app/(app)/create/components/preview-switcher.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||||
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
|
const PREVIEW_ITEMS = [
|
||||||
|
{ label: "01", value: "preview-02" },
|
||||||
|
{ label: "02", value: "preview" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PreviewSwitcher() {
|
||||||
|
const [params, setParams] = useDesignSystemSearchParams()
|
||||||
|
|
||||||
|
const isPreview =
|
||||||
|
params.item === "preview" || params.item.startsWith("preview-0")
|
||||||
|
|
||||||
|
if (!isPreview) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dark absolute right-3 bottom-3 z-20 flex items-center gap-1 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
|
||||||
|
{PREVIEW_ITEMS.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.value}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
data-active={params.item === item.value}
|
||||||
|
className="h-7 min-w-8 cursor-pointer rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
|
||||||
|
onClick={() => setParams({ item: item.value })}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
apps/v4/app/(app)/create/components/preview.tsx
Normal file
166
apps/v4/app/(app)/create/components/preview.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/create/components/action-menu"
|
||||||
|
import {
|
||||||
|
REDO_FORWARD_TYPE,
|
||||||
|
UNDO_FORWARD_TYPE,
|
||||||
|
} from "@/app/(app)/create/components/history-buttons"
|
||||||
|
import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/create/components/mode-switcher"
|
||||||
|
import { PreviewSwitcher } from "@/app/(app)/create/components/preview-switcher"
|
||||||
|
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/create/components/random-button"
|
||||||
|
import { sendToIframe } from "@/app/(app)/create/hooks/use-iframe-sync"
|
||||||
|
import { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-open-preset"
|
||||||
|
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
|
||||||
|
import {
|
||||||
|
serializeDesignSystemSearchParams,
|
||||||
|
useDesignSystemSearchParams,
|
||||||
|
} from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
|
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
|
||||||
|
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
|
||||||
|
|
||||||
|
export function Preview() {
|
||||||
|
const [params] = useDesignSystemSearchParams()
|
||||||
|
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const iframe = iframeRef.current
|
||||||
|
if (!iframe) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendParams = () => {
|
||||||
|
sendToIframe(iframe, "design-system-params", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iframe.contentWindow) {
|
||||||
|
sendParams()
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe.addEventListener("load", sendParams)
|
||||||
|
return () => {
|
||||||
|
iframe.removeEventListener("load", sendParams)
|
||||||
|
}
|
||||||
|
}, [params])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
const iframeWindow = iframeRef.current?.contentWindow
|
||||||
|
if (
|
||||||
|
!iframeWindow ||
|
||||||
|
event.origin !== window.location.origin ||
|
||||||
|
event.source !== iframeWindow ||
|
||||||
|
!event.data ||
|
||||||
|
typeof event.data !== "object"
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = event.data.type
|
||||||
|
if (type === CMD_K_FORWARD_TYPE) {
|
||||||
|
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: event.data.key || "k",
|
||||||
|
metaKey: isMac,
|
||||||
|
ctrlKey: !isMac,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else if (type === RANDOMIZE_FORWARD_TYPE) {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: event.data.key || "r",
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else if (type === OPEN_PRESET_FORWARD_TYPE) {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: event.data.key || "o",
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else if (type === UNDO_FORWARD_TYPE) {
|
||||||
|
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: "z",
|
||||||
|
metaKey: isMac,
|
||||||
|
ctrlKey: !isMac,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else if (type === REDO_FORWARD_TYPE) {
|
||||||
|
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: "z",
|
||||||
|
shiftKey: true,
|
||||||
|
metaKey: isMac,
|
||||||
|
ctrlKey: !isMac,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else if (type === RESET_FORWARD_TYPE) {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: "R",
|
||||||
|
shiftKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else if (type === DARK_MODE_FORWARD_TYPE) {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: event.data.key || "d",
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", handleMessage)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("message", handleMessage)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const iframeSrc = React.useMemo(() => {
|
||||||
|
// The iframe src needs to include the serialized design system params
|
||||||
|
// for the initial load, but not be reactive to them as it would cause
|
||||||
|
// full-iframe reloads on every param change (flashes & loss of state).
|
||||||
|
// Further updates of the search params will be sent to the iframe
|
||||||
|
// via a postMessage channel, for it to sync its own history onto the host's.
|
||||||
|
return serializeDesignSystemSearchParams(
|
||||||
|
`/preview/${params.base}/${params.item}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [params.base, params.item])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-1 flex-col justify-center overflow-hidden rounded-2xl ring ring-foreground/10 md:ring-muted dark:ring-foreground/10">
|
||||||
|
<div className="relative z-0 mx-auto flex w-full flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-muted dark:bg-muted/30" />
|
||||||
|
<iframe
|
||||||
|
key={params.base + params.item}
|
||||||
|
ref={iframeRef}
|
||||||
|
src={iframeSrc}
|
||||||
|
className="z-10 size-full flex-1"
|
||||||
|
title="Preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PreviewSwitcher />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Button } from "@/examples/base/ui/button"
|
import {
|
||||||
|
Copy01Icon,
|
||||||
|
Globe02Icon,
|
||||||
|
HandPointingRight04Icon,
|
||||||
|
Tick02Icon,
|
||||||
|
} from "@hugeicons/core-free-icons"
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useConfig } from "@/hooks/use-config"
|
||||||
|
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||||
|
import { BASES, type BaseName } from "@/registry/config"
|
||||||
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,7 +22,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/examples/base/ui/dialog"
|
} from "@/styles/base-nova/ui/dialog"
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldContent,
|
FieldContent,
|
||||||
@@ -20,32 +32,26 @@ import {
|
|||||||
FieldSeparator,
|
FieldSeparator,
|
||||||
FieldSet,
|
FieldSet,
|
||||||
FieldTitle,
|
FieldTitle,
|
||||||
} from "@/examples/base/ui/field"
|
} from "@/styles/base-nova/ui/field"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/examples/base/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/styles/base-nova/ui/radio-group"
|
||||||
import { Switch } from "@/examples/base/ui/switch"
|
import { Switch } from "@/styles/base-nova/ui/switch"
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@/examples/base/ui/tabs"
|
} from "@/styles/base-nova/ui/tabs"
|
||||||
import { Copy01Icon, Globe02Icon, Tick02Icon } from "@hugeicons/core-free-icons"
|
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { useConfig } from "@/hooks/use-config"
|
|
||||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
|
||||||
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
|
|
||||||
import {
|
import {
|
||||||
useDesignSystemSearchParams,
|
useDesignSystemSearchParams,
|
||||||
type DesignSystemSearchParams,
|
type DesignSystemSearchParams,
|
||||||
} from "@/app/(create)/lib/search-params"
|
} from "@/app/(app)/create/lib/search-params"
|
||||||
import {
|
import {
|
||||||
getFramework,
|
getFramework,
|
||||||
getTemplateValue,
|
getTemplateValue,
|
||||||
NO_MONOREPO_FRAMEWORKS,
|
NO_MONOREPO_FRAMEWORKS,
|
||||||
TEMPLATES,
|
TEMPLATES,
|
||||||
} from "@/app/(create)/lib/templates"
|
} from "@/app/(app)/create/lib/templates"
|
||||||
|
|
||||||
const TURBOREPO_LOGO =
|
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>'
|
'<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>'
|
||||||
@@ -84,9 +90,10 @@ export function ProjectForm({
|
|||||||
const templateFlag = ` --template ${framework}`
|
const templateFlag = ` --template ${framework}`
|
||||||
const monorepoFlag = isMonorepo ? " --monorepo" : ""
|
const monorepoFlag = isMonorepo ? " --monorepo" : ""
|
||||||
const rtlFlag = params.rtl ? " --rtl" : ""
|
const rtlFlag = params.rtl ? " --rtl" : ""
|
||||||
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}`
|
const pointerFlag = params.pointer ? " --pointer" : ""
|
||||||
|
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}${pointerFlag}`
|
||||||
|
|
||||||
return IS_LOCAL_DEV && !process.env.NEXT_PUBLIC_RC
|
return IS_LOCAL_DEV
|
||||||
? {
|
? {
|
||||||
pnpm: `shadcn init${flags}`,
|
pnpm: `shadcn init${flags}`,
|
||||||
npm: `shadcn init${flags}`,
|
npm: `shadcn init${flags}`,
|
||||||
@@ -99,7 +106,14 @@ export function ProjectForm({
|
|||||||
yarn: `yarn dlx shadcn${SHADCN_VERSION} init${flags}`,
|
yarn: `yarn dlx shadcn${SHADCN_VERSION} init${flags}`,
|
||||||
bun: `bunx --bun shadcn${SHADCN_VERSION} init${flags}`,
|
bun: `bunx --bun shadcn${SHADCN_VERSION} init${flags}`,
|
||||||
}
|
}
|
||||||
}, [framework, isMonorepo, params.base, params.rtl, presetCode])
|
}, [
|
||||||
|
framework,
|
||||||
|
isMonorepo,
|
||||||
|
params.base,
|
||||||
|
params.pointer,
|
||||||
|
params.rtl,
|
||||||
|
presetCode,
|
||||||
|
])
|
||||||
|
|
||||||
const command = commands[packageManager]
|
const command = commands[packageManager]
|
||||||
|
|
||||||
@@ -129,69 +143,93 @@ export function ProjectForm({
|
|||||||
<DialogTrigger render={<Button className={cn(className)} />}>
|
<DialogTrigger render={<Button className={cn(className)} />}>
|
||||||
Create Project
|
Create Project
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="min-w-0 sm:max-w-sm">
|
<DialogContent className="dark no-scrollbar max-h-[calc(100svh-2rem)] overflow-y-auto rounded-2xl p-6 shadow-xl **:data-[slot=field-separator]:h-2 sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Project</DialogTitle>
|
<DialogTitle>Create Project</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Pick a template and configure your project. Available for all major
|
Pick a template and configure your project.
|
||||||
React frameworks.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<FieldGroup>
|
<div>
|
||||||
<Field>
|
<FieldGroup>
|
||||||
<FieldLabel className="sr-only">Template</FieldLabel>
|
<FieldSeparator className="-mx-6" />
|
||||||
<TemplateGrid template={params.template} setParams={setParams} />
|
<Field className="-mt-2 gap-3">
|
||||||
</Field>
|
<FieldLabel>Template</FieldLabel>
|
||||||
<FieldSeparator />
|
<TemplateGrid template={params.template} setParams={setParams} />
|
||||||
<FieldSet>
|
</Field>
|
||||||
<FieldLegend variant="label" className="sr-only">
|
<FieldSeparator className="-mx-6" />
|
||||||
Options
|
<Field className="-mt-2">
|
||||||
</FieldLegend>
|
<FieldLabel>Base</FieldLabel>
|
||||||
<Field
|
<BaseGrid base={params.base} setParams={setParams} />
|
||||||
orientation="horizontal"
|
</Field>
|
||||||
data-disabled={hasMonorepo ? undefined : "true"}
|
<FieldSeparator className="-mx-6" />
|
||||||
>
|
<FieldSet>
|
||||||
<FieldLabel htmlFor="monorepo">
|
<FieldLegend variant="label" className="sr-only">
|
||||||
<span
|
Options
|
||||||
className="size-4 text-foreground [&_svg]:size-4 [&_svg]:fill-current"
|
</FieldLegend>
|
||||||
dangerouslySetInnerHTML={{
|
<Field orientation="horizontal">
|
||||||
__html: TURBOREPO_LOGO,
|
<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,
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
Create a monorepo
|
</Field>
|
||||||
</FieldLabel>
|
<FieldSeparator className="-mx-6" />
|
||||||
<Switch
|
<Field orientation="horizontal">
|
||||||
id="monorepo"
|
<FieldLabel htmlFor="rtl">
|
||||||
checked={params.template?.endsWith("-monorepo") ?? false}
|
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
|
||||||
disabled={!hasMonorepo}
|
Enable RTL support
|
||||||
onCheckedChange={(checked) => {
|
</FieldLabel>
|
||||||
const framework = getFramework(params.template ?? "next")
|
<Switch
|
||||||
setParams({
|
id="rtl"
|
||||||
template: getTemplateValue(
|
checked={params.rtl}
|
||||||
framework,
|
onCheckedChange={(checked) =>
|
||||||
checked === true
|
setParams({ rtl: checked === true })
|
||||||
) as typeof params.template,
|
}
|
||||||
})
|
/>
|
||||||
}}
|
</Field>
|
||||||
/>
|
</FieldSet>
|
||||||
</Field>
|
</FieldGroup>
|
||||||
<FieldSeparator />
|
</div>
|
||||||
<Field orientation="horizontal">
|
<DialogFooter className="-mx-6 -mb-6 min-w-0">
|
||||||
<FieldLabel htmlFor="rtl">
|
|
||||||
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
|
|
||||||
Enable RTL support
|
|
||||||
</FieldLabel>
|
|
||||||
<Switch
|
|
||||||
id="rtl"
|
|
||||||
checked={params.rtl}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setParams({ rtl: checked === true })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldSet>
|
|
||||||
</FieldGroup>
|
|
||||||
<DialogFooter className="min-w-0">
|
|
||||||
<div className="flex w-full min-w-0 flex-col gap-3">
|
<div className="flex w-full min-w-0 flex-col gap-3">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={packageManager}
|
value={packageManager}
|
||||||
@@ -201,16 +239,16 @@ export function ProjectForm({
|
|||||||
packageManager: value as PackageManager,
|
packageManager: value as PackageManager,
|
||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
className="min-w-0 gap-0 overflow-hidden rounded-lg border bg-surface"
|
className="min-w-0 gap-0 overflow-hidden rounded-xl border-0 ring-1 ring-border"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 px-1 py-1">
|
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
|
||||||
<TabsList className="font-mono">
|
<TabsList className="bg-transparent font-mono">
|
||||||
{PACKAGE_MANAGERS.map((manager) => {
|
{PACKAGE_MANAGERS.map((manager) => {
|
||||||
return (
|
return (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={manager}
|
key={manager}
|
||||||
value={manager}
|
value={manager}
|
||||||
className="data-[state=active]:shadow-none"
|
className="py-0 leading-none data-[state=active]:shadow-none"
|
||||||
>
|
>
|
||||||
{manager}
|
{manager}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -234,7 +272,7 @@ export function ProjectForm({
|
|||||||
{Object.entries(commands).map(([key, cmd]) => {
|
{Object.entries(commands).map(([key, cmd]) => {
|
||||||
return (
|
return (
|
||||||
<TabsContent key={key} value={key}>
|
<TabsContent key={key} value={key}>
|
||||||
<div className="relative overflow-hidden border-t border-border/50 bg-surface px-3 py-3 text-surface-foreground">
|
<div className="relative overflow-hidden border-t bg-popover p-3">
|
||||||
<div className="no-scrollbar overflow-x-auto">
|
<div className="no-scrollbar overflow-x-auto">
|
||||||
<code className="font-mono text-sm whitespace-nowrap">
|
<code className="font-mono text-sm whitespace-nowrap">
|
||||||
{cmd}
|
{cmd}
|
||||||
@@ -281,23 +319,26 @@ const TemplateGrid = React.memo(function TemplateGrid({
|
|||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={framework}
|
value={framework}
|
||||||
onValueChange={handleTemplateChange}
|
onValueChange={handleTemplateChange}
|
||||||
className="grid grid-cols-3 gap-2"
|
className="grid grid-cols-2 gap-2"
|
||||||
>
|
>
|
||||||
{TEMPLATES.map((item) => (
|
{TEMPLATES.map((item) => (
|
||||||
<FieldLabel
|
<FieldLabel
|
||||||
key={item.value}
|
key={item.value}
|
||||||
htmlFor={`template-${item.value}`}
|
htmlFor={`template-${item.value}`}
|
||||||
className="py-1"
|
className="block w-full"
|
||||||
>
|
>
|
||||||
<Field className="gap-0" orientation="horizontal">
|
<Field
|
||||||
<FieldContent className="flex flex-col items-center justify-center gap-2">
|
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
|
<div
|
||||||
className="size-6 text-foreground [&_svg]:size-6 *:[svg]:text-foreground!"
|
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: item.logo,
|
__html: item.logo,
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
<FieldTitle className="text-xs">{item.title}</FieldTitle>
|
<FieldTitle>{item.title}</FieldTitle>
|
||||||
</FieldContent>
|
</FieldContent>
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
value={item.value}
|
value={item.value}
|
||||||
@@ -310,3 +351,55 @@ const TemplateGrid = React.memo(function TemplateGrid({
|
|||||||
</RadioGroup>
|
</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/(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/(create)/components/picker"
|
} from "@/app/(app)/create/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
export function RadiusPicker({
|
export function RadiusPicker({
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -23,7 +23,7 @@ export function RadiusPicker({
|
|||||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||||
}) {
|
}) {
|
||||||
const [params, setParams] = useDesignSystemSearchParams()
|
const [params, setParams] = useDesignSystemSearchParams()
|
||||||
const isRadiusLocked = params.style === "lyra"
|
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
|
||||||
const selectedRadiusName = isRadiusLocked ? "none" : params.radius
|
const selectedRadiusName = isRadiusLocked ? "none" : params.radius
|
||||||
|
|
||||||
const currentRadius = RADII.find(
|
const currentRadius = RADII.find(
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
import { Button } from "@/examples/base/ui/button"
|
|
||||||
import { DiceFaces05Icon } from "@hugeicons/core-free-icons"
|
import { DiceFaces05Icon } from "@hugeicons/core-free-icons"
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
import { HugeiconsIcon } from "@hugeicons/react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useRandom } from "@/app/(create)/hooks/use-random"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
|
import { useRandom } from "@/app/(app)/create/hooks/use-random"
|
||||||
|
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
|
||||||
|
|
||||||
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
|
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export function RandomButton({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="w-full text-center font-medium">Shuffle</span>
|
<span className="w-full truncate text-center font-medium">Shuffle</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ export function RandomizeScript() {
|
|||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
(function() {
|
(function() {
|
||||||
// Forward R key
|
// Forward r key (shuffle) and Shift+R (reset).
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if ((e.key === 'r' || e.key === 'R') && !e.metaKey && !e.ctrlKey) {
|
if ((e.key === 'r' || e.key === 'R') && !e.metaKey && !e.ctrlKey) {
|
||||||
if (
|
if (
|
||||||
@@ -53,8 +54,11 @@ export function RandomizeScript() {
|
|||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (window.parent && window.parent !== window) {
|
if (window.parent && window.parent !== window) {
|
||||||
|
var type = e.shiftKey
|
||||||
|
? '${RESET_FORWARD_TYPE}'
|
||||||
|
: '${RANDOMIZE_FORWARD_TYPE}';
|
||||||
window.parent.postMessage({
|
window.parent.postMessage({
|
||||||
type: '${RANDOMIZE_FORWARD_TYPE}',
|
type: type,
|
||||||
key: e.key
|
key: e.key
|
||||||
}, '*');
|
}, '*');
|
||||||
}
|
}
|
||||||
@@ -9,9 +9,8 @@ import {
|
|||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/examples/base/ui/alert-dialog"
|
} from "@/styles/base-nova/ui/alert-dialog"
|
||||||
|
import { useReset } from "@/app/(app)/create/hooks/use-reset"
|
||||||
import { useReset } from "@/app/(create)/hooks/use-reset"
|
|
||||||
|
|
||||||
export function ResetDialog() {
|
export function ResetDialog() {
|
||||||
const { showResetDialog, setShowResetDialog, confirmReset } = useReset()
|
const { showResetDialog, setShowResetDialog, confirmReset } = useReset()
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Button } from "@/examples/base/ui/button"
|
|
||||||
import { Share03Icon, Tick02Icon } from "@hugeicons/core-free-icons"
|
import { Share03Icon, Tick02Icon } from "@hugeicons/core-free-icons"
|
||||||
import { HugeiconsIcon } from "@hugeicons/react"
|
import { HugeiconsIcon } from "@hugeicons/react"
|
||||||
|
|
||||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||||
import { usePresetCode } from "@/app/(create)/hooks/use-design-system"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
|
||||||
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
export function ShareButton() {
|
export function ShareButton() {
|
||||||
const [params] = useDesignSystemSearchParams()
|
const [params] = useDesignSystemSearchParams()
|
||||||
@@ -16,8 +16,17 @@ export function ShareButton() {
|
|||||||
|
|
||||||
const shareUrl = React.useMemo(() => {
|
const shareUrl = React.useMemo(() => {
|
||||||
const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
|
const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
|
||||||
return `${origin}/create?preset=${presetCode}&item=${params.item}`
|
const searchParams = new URLSearchParams({
|
||||||
}, [presetCode, params.item])
|
preset: presetCode,
|
||||||
|
item: params.item,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (params.pointer) {
|
||||||
|
searchParams.set("pointer", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${origin}/create?${searchParams.toString()}`
|
||||||
|
}, [params.item, params.pointer, presetCode])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (hasCopied) {
|
if (hasCopied) {
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { type Style, type StyleName } from "@/registry/config"
|
import { PRESETS, type Style, type StyleName } from "@/registry/config"
|
||||||
import { LockButton } from "@/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/(create)/components/picker"
|
} from "@/app/(app)/create/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
export function StylePicker({
|
export function StylePicker({
|
||||||
styles,
|
styles,
|
||||||
@@ -53,7 +53,24 @@ export function StylePicker({
|
|||||||
<PickerRadioGroup
|
<PickerRadioGroup
|
||||||
value={currentStyle?.name}
|
value={currentStyle?.name}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setParams({ style: value as StyleName })
|
const styleName = value as StyleName
|
||||||
|
const preset = PRESETS.find(
|
||||||
|
(p) => p.base === params.base && p.style === styleName
|
||||||
|
)
|
||||||
|
setParams({
|
||||||
|
style: styleName,
|
||||||
|
...(preset && {
|
||||||
|
baseColor: preset.baseColor,
|
||||||
|
theme: preset.theme,
|
||||||
|
chartColor: preset.chartColor,
|
||||||
|
iconLibrary: preset.iconLibrary,
|
||||||
|
font: preset.font,
|
||||||
|
fontHeading: preset.fontHeading,
|
||||||
|
menuAccent: preset.menuAccent,
|
||||||
|
menuColor: preset.menuColor,
|
||||||
|
radius: preset.radius,
|
||||||
|
}),
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PickerGroup>
|
<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/(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/(create)/components/picker"
|
} from "@/app/(app)/create/components/picker"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
export function ThemePicker({
|
export function ThemePicker({
|
||||||
themes,
|
themes,
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Button } from "@/examples/base/ui/button"
|
|
||||||
import { Skeleton } from "@/examples/base/ui/skeleton"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { useMounted } from "@/hooks/use-mounted"
|
import { useMounted } from "@/hooks/use-mounted"
|
||||||
import { Icons } from "@/components/icons"
|
import { Icons } from "@/components/icons"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
|
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
||||||
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
export function V0Button({ className }: { className?: string }) {
|
export function V0Button({ className }: { className?: string }) {
|
||||||
const [params] = useDesignSystemSearchParams()
|
const [params] = useDesignSystemSearchParams()
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Button } from "@/examples/base/ui/button"
|
|
||||||
|
import { Icons } from "@/components/icons"
|
||||||
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -10,9 +12,7 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/examples/base/ui/dialog"
|
} from "@/styles/base-nova/ui/dialog"
|
||||||
|
|
||||||
import { Icons } from "@/components/icons"
|
|
||||||
|
|
||||||
const STORAGE_KEY = "shadcn-create-welcome-dialog"
|
const STORAGE_KEY = "shadcn-create-welcome-dialog"
|
||||||
|
|
||||||
@@ -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/(create)/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
import { groupItemsByType } from "@/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"
|
||||||
|
|
||||||
11
apps/v4/app/(app)/create/hooks/use-design-system.ts
Normal file
11
apps/v4/app/(app)/create/hooks/use-design-system.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
|
||||||
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
|
// Returns the canonical preset code derived from the current search params.
|
||||||
|
export function usePresetCode() {
|
||||||
|
const [params] = useDesignSystemSearchParams()
|
||||||
|
|
||||||
|
return getPresetCode(params)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { Suspense } from "react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
type HistoryContextValue = {
|
type HistoryContextValue = {
|
||||||
canGoBack: boolean
|
canGoBack: boolean
|
||||||
@@ -12,12 +13,28 @@ type HistoryContextValue = {
|
|||||||
|
|
||||||
const HistoryContext = React.createContext<HistoryContextValue | null>(null)
|
const HistoryContext = React.createContext<HistoryContextValue | null>(null)
|
||||||
|
|
||||||
export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
// Reads useSearchParams() in its own Suspense boundary so the
|
||||||
const router = useRouter()
|
// provider never blanks out children while search params resolve.
|
||||||
const pathname = usePathname()
|
function PresetSync({
|
||||||
|
onPresetChange,
|
||||||
|
}: {
|
||||||
|
onPresetChange: (preset: string) => void
|
||||||
|
}) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const preset = searchParams.get("preset") ?? ""
|
const preset = searchParams.get("preset") ?? ""
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onPresetChange(preset)
|
||||||
|
}, [preset, onPresetChange])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const [preset, setPreset] = React.useState("")
|
||||||
|
|
||||||
const entriesRef = React.useRef<string[]>([preset])
|
const entriesRef = React.useRef<string[]>([preset])
|
||||||
const indexRef = React.useRef(0)
|
const indexRef = React.useRef(0)
|
||||||
const maxIndexRef = React.useRef(0)
|
const maxIndexRef = React.useRef(0)
|
||||||
@@ -26,6 +43,10 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [index, setIndex] = React.useState(0)
|
const [index, setIndex] = React.useState(0)
|
||||||
const [maxIndex, setMaxIndex] = React.useState(0)
|
const [maxIndex, setMaxIndex] = React.useState(0)
|
||||||
|
|
||||||
|
const onPresetChange = React.useCallback((nextPreset: string) => {
|
||||||
|
setPreset(nextPreset)
|
||||||
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isNavigatingRef.current) {
|
if (isNavigatingRef.current) {
|
||||||
isNavigatingRef.current = false
|
isNavigatingRef.current = false
|
||||||
@@ -67,9 +88,10 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} else {
|
} else {
|
||||||
params.delete("preset")
|
params.delete("preset")
|
||||||
}
|
}
|
||||||
|
const pathname = window.location.pathname
|
||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
router.replace(query ? `${pathname}?${query}` : pathname)
|
router.replace(query ? `${pathname}?${query}` : pathname)
|
||||||
}, [pathname, router])
|
}, [router])
|
||||||
|
|
||||||
const goForward = React.useCallback(() => {
|
const goForward = React.useCallback(() => {
|
||||||
if (indexRef.current >= maxIndexRef.current) {
|
if (indexRef.current >= maxIndexRef.current) {
|
||||||
@@ -88,9 +110,10 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} else {
|
} else {
|
||||||
params.delete("preset")
|
params.delete("preset")
|
||||||
}
|
}
|
||||||
|
const pathname = window.location.pathname
|
||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
router.replace(query ? `${pathname}?${query}` : pathname)
|
router.replace(query ? `${pathname}?${query}` : pathname)
|
||||||
}, [pathname, router])
|
}, [router])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
@@ -133,7 +156,14 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
[canGoBack, canGoForward, goBack, goForward]
|
[canGoBack, canGoForward, goBack, goForward]
|
||||||
)
|
)
|
||||||
|
|
||||||
return <HistoryContext value={value}>{children}</HistoryContext>
|
return (
|
||||||
|
<HistoryContext value={value}>
|
||||||
|
<Suspense>
|
||||||
|
<PresetSync onPresetChange={onPresetChange} />
|
||||||
|
</Suspense>
|
||||||
|
{children}
|
||||||
|
</HistoryContext>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHistory() {
|
export function useHistory() {
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import type { DesignSystemSearchParams } from "@/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"
|
||||||
@@ -6,8 +6,10 @@ export type LockableParam =
|
|||||||
| "style"
|
| "style"
|
||||||
| "baseColor"
|
| "baseColor"
|
||||||
| "theme"
|
| "theme"
|
||||||
|
| "chartColor"
|
||||||
| "iconLibrary"
|
| "iconLibrary"
|
||||||
| "font"
|
| "font"
|
||||||
|
| "fontHeading"
|
||||||
| "menuAccent"
|
| "menuAccent"
|
||||||
| "menuColor"
|
| "menuColor"
|
||||||
| "radius"
|
| "radius"
|
||||||
@@ -22,10 +24,15 @@ 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) => locks.has(param),
|
(param: LockableParam) => locksRef.current.has(param),
|
||||||
[locks]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleLock = React.useCallback((param: LockableParam) => {
|
const toggleLock = React.useCallback((param: LockableParam) => {
|
||||||
81
apps/v4/app/(app)/create/hooks/use-open-preset.tsx
Normal file
81
apps/v4/app/(app)/create/hooks/use-open-preset.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import useSWR from "swr"
|
||||||
|
|
||||||
|
const OPEN_PRESET_KEY = "create:open-preset-open"
|
||||||
|
export const OPEN_PRESET_FORWARD_TYPE = "open-preset-forward"
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
|
return (
|
||||||
|
(target instanceof HTMLElement && target.isContentEditable) ||
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLSelectElement
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpenPreset() {
|
||||||
|
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
|
||||||
|
OPEN_PRESET_KEY,
|
||||||
|
{
|
||||||
|
fallbackData: false,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenChange = React.useCallback(
|
||||||
|
(nextOpen: boolean) => {
|
||||||
|
void setOpenData(nextOpen, { revalidate: false })
|
||||||
|
},
|
||||||
|
[setOpenData]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
e.key === "o" &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.altKey
|
||||||
|
) {
|
||||||
|
if (isEditableTarget(e.target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
void setOpenData(true, { revalidate: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", down)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", down)
|
||||||
|
}
|
||||||
|
}, [setOpenData])
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
setOpen: handleOpenChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpenPresetTrigger() {
|
||||||
|
const { mutate: setOpenData } = useSWR<boolean>(OPEN_PRESET_KEY, {
|
||||||
|
fallbackData: false,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openPreset = React.useCallback(() => {
|
||||||
|
void setOpenData(true, { revalidate: false })
|
||||||
|
}, [setOpenData])
|
||||||
|
|
||||||
|
return {
|
||||||
|
openPreset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,18 +10,19 @@ import {
|
|||||||
MENU_COLORS,
|
MENU_COLORS,
|
||||||
RADII,
|
RADII,
|
||||||
STYLES,
|
STYLES,
|
||||||
|
type FontHeadingValue,
|
||||||
} from "@/registry/config"
|
} from "@/registry/config"
|
||||||
import { useLocks } from "@/app/(create)/hooks/use-locks"
|
import { useLocks } from "@/app/(app)/create/hooks/use-locks"
|
||||||
import { FONTS } from "@/app/(create)/lib/fonts"
|
import { FONTS } from "@/app/(app)/create/lib/fonts"
|
||||||
import {
|
import {
|
||||||
applyBias,
|
applyBias,
|
||||||
RANDOMIZE_BIASES,
|
RANDOMIZE_BIASES,
|
||||||
type RandomizeContext,
|
type RandomizeContext,
|
||||||
} from "@/app/(create)/lib/randomize-biases"
|
} from "@/app/(app)/create/lib/randomize-biases"
|
||||||
import {
|
import {
|
||||||
isTranslucentMenuColor,
|
isTranslucentMenuColor,
|
||||||
useDesignSystemSearchParams,
|
useDesignSystemSearchParams,
|
||||||
} from "@/app/(create)/lib/search-params"
|
} from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
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)]
|
||||||
@@ -62,9 +63,41 @@ export function useRandom() {
|
|||||||
const selectedTheme = locks.has("theme")
|
const selectedTheme = locks.has("theme")
|
||||||
? paramsRef.current.theme
|
? paramsRef.current.theme
|
||||||
: randomItem(availableThemes).name
|
: randomItem(availableThemes).name
|
||||||
|
context.theme = selectedTheme
|
||||||
|
|
||||||
|
const availableChartColors = applyBias(
|
||||||
|
getThemesForBaseColor(baseColor),
|
||||||
|
context,
|
||||||
|
RANDOMIZE_BIASES.chartColors
|
||||||
|
)
|
||||||
|
const selectedChartColor = locks.has("chartColor")
|
||||||
|
? paramsRef.current.chartColor
|
||||||
|
: randomItem(availableChartColors).name
|
||||||
|
context.chartColor = selectedChartColor
|
||||||
const selectedFont = locks.has("font")
|
const selectedFont = locks.has("font")
|
||||||
? paramsRef.current.font
|
? paramsRef.current.font
|
||||||
: randomItem(availableFonts).value
|
: randomItem(availableFonts).value
|
||||||
|
context.font = selectedFont
|
||||||
|
|
||||||
|
// Pick heading font: ~70% inherit, ~30% distinct with cross-category contrast.
|
||||||
|
let selectedFontHeading: FontHeadingValue
|
||||||
|
if (locks.has("fontHeading")) {
|
||||||
|
selectedFontHeading = paramsRef.current.fontHeading
|
||||||
|
} else if (Math.random() < 0.7) {
|
||||||
|
selectedFontHeading = "inherit"
|
||||||
|
} else {
|
||||||
|
const bodyType = availableFonts.find(
|
||||||
|
(f) => f.value === selectedFont
|
||||||
|
)?.type
|
||||||
|
const contrastFonts = availableFonts.filter(
|
||||||
|
(f) => f.type !== bodyType && f.value !== selectedFont
|
||||||
|
)
|
||||||
|
selectedFontHeading = (
|
||||||
|
contrastFonts.length > 0
|
||||||
|
? randomItem(contrastFonts)
|
||||||
|
: randomItem(availableFonts)
|
||||||
|
).value as FontHeadingValue
|
||||||
|
}
|
||||||
const selectedRadius = locks.has("radius")
|
const selectedRadius = locks.has("radius")
|
||||||
? paramsRef.current.radius
|
? paramsRef.current.radius
|
||||||
: randomItem(availableRadii).name
|
: randomItem(availableRadii).name
|
||||||
@@ -91,16 +124,16 @@ export function useRandom() {
|
|||||||
: paramsRef.current.menuAccent
|
: paramsRef.current.menuAccent
|
||||||
: randomItem(MENU_ACCENTS).value
|
: randomItem(MENU_ACCENTS).value
|
||||||
|
|
||||||
context.theme = selectedTheme
|
|
||||||
context.font = selectedFont
|
|
||||||
context.radius = selectedRadius
|
context.radius = selectedRadius
|
||||||
|
|
||||||
const nextParams = {
|
const nextParams = {
|
||||||
style: selectedStyle,
|
style: selectedStyle,
|
||||||
baseColor,
|
baseColor,
|
||||||
theme: selectedTheme,
|
theme: selectedTheme,
|
||||||
|
chartColor: selectedChartColor,
|
||||||
iconLibrary: selectedIconLibrary,
|
iconLibrary: selectedIconLibrary,
|
||||||
font: selectedFont,
|
font: selectedFont,
|
||||||
|
fontHeading: selectedFontHeading,
|
||||||
menuAccent: selectedMenuAccent,
|
menuAccent: selectedMenuAccent,
|
||||||
menuColor: selectedMenuColor,
|
menuColor: selectedMenuColor,
|
||||||
radius: selectedRadius,
|
radius: selectedRadius,
|
||||||
@@ -123,7 +156,7 @@ export function useRandom() {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if ((e.key === "r" || e.key === "R") && !e.metaKey && !e.ctrlKey) {
|
if (e.key === "r" && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||||
if (
|
if (
|
||||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||||
e.target instanceof HTMLInputElement ||
|
e.target instanceof HTMLInputElement ||
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user