mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-07 14:08:47 +00:00
Compare commits
973 Commits
shadcn@3.8
...
changeset-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc95f4983 | ||
|
|
68e1f171aa | ||
|
|
dd6ce77cf1 | ||
|
|
628e427231 | ||
|
|
d8ace420ba | ||
|
|
4163c1dbea | ||
|
|
468674b1f0 | ||
|
|
b13050763e | ||
|
|
c4b22943fe | ||
|
|
34f0dabe48 | ||
|
|
5b5db1f8ea | ||
|
|
d0fae52822 | ||
|
|
2ef6388ca1 | ||
|
|
f3e7de1175 | ||
|
|
a409271270 | ||
|
|
70afde8358 | ||
|
|
574e85e22d | ||
|
|
3310c45dfb | ||
|
|
34223a842d | ||
|
|
dbf9c5ebc4 | ||
|
|
897e9add14 | ||
|
|
8d6553a7f5 | ||
|
|
683073f102 | ||
|
|
02e398ab73 | ||
|
|
af79276f7e | ||
|
|
5a3ad36a5e | ||
|
|
a63e8359ec | ||
|
|
a72491cb9b | ||
|
|
67aec7dcc5 | ||
|
|
40c7064532 | ||
|
|
5b3369f6ee | ||
|
|
b31e6d63b0 | ||
|
|
cf5b227565 | ||
|
|
8055a12f46 | ||
|
|
18fcf0f766 | ||
|
|
c520191cd4 | ||
|
|
35983528c2 | ||
|
|
8692cd4cc1 | ||
|
|
d3727d8c45 | ||
|
|
e3f98d49e4 | ||
|
|
549852bffc | ||
|
|
4139cff3a0 | ||
|
|
95471a0fb9 | ||
|
|
b59f68ecc5 | ||
|
|
6956a099b3 | ||
|
|
2417242b12 | ||
|
|
70a7a0c2a8 | ||
|
|
5602b81d83 | ||
|
|
3ffd3e1c7c | ||
|
|
e03df56fcf | ||
|
|
38fb1b6f41 | ||
|
|
13eb6a81d1 | ||
|
|
5cc8a2af42 | ||
|
|
365d53b590 | ||
|
|
c2ddedf5d2 | ||
|
|
c879483c96 | ||
|
|
4885a9a7ad | ||
|
|
2f5929269a | ||
|
|
2fdf1bb0d4 | ||
|
|
951750bdbe | ||
|
|
6c5d5d6374 | ||
|
|
6ec117715f | ||
|
|
e583c773fe | ||
|
|
0f154171a7 | ||
|
|
9197676b3d | ||
|
|
82dce7e945 | ||
|
|
35bc9934bf | ||
|
|
1fd75c9d7e | ||
|
|
2ea31d8070 | ||
|
|
ea9d371a2d | ||
|
|
d15f17d717 | ||
|
|
46ca8c5d4b | ||
|
|
a5eb279650 | ||
|
|
1994caba0b | ||
|
|
1450bea8d6 | ||
|
|
ced2a5beb5 | ||
|
|
10f1717a3e | ||
|
|
deba036d50 | ||
|
|
5bd81beebf | ||
|
|
3f2ff18157 | ||
|
|
05eb2b968b | ||
|
|
a721cc08e5 | ||
|
|
8da4592308 | ||
|
|
f47d48f316 | ||
|
|
e6d9d6023b | ||
|
|
7dfd933102 | ||
|
|
9c6a5ee1b1 | ||
|
|
c87897b2a5 | ||
|
|
c61197f627 | ||
|
|
a1fb619cef | ||
|
|
d84c4a8ca5 | ||
|
|
cd54e0927f | ||
|
|
adac7cae1f | ||
|
|
7c63c46736 | ||
|
|
916c012132 | ||
|
|
460ad60d84 | ||
|
|
8e2d2d1439 | ||
|
|
67cef8fcb9 | ||
|
|
4ff43ba694 | ||
|
|
efdec3ca45 | ||
|
|
5c849297d0 | ||
|
|
2baa86081d | ||
|
|
980f288149 | ||
|
|
07900769d9 | ||
|
|
360e8a19c3 | ||
|
|
e2fa0101e3 | ||
|
|
55ea86f252 | ||
|
|
f584f05489 | ||
|
|
a06ba18dcc | ||
|
|
f3e16e7db7 | ||
|
|
64afddefd9 | ||
|
|
c873713992 | ||
|
|
3751fdfa4c | ||
|
|
c824d6b78d | ||
|
|
df1752dfe0 | ||
|
|
e826e543f2 | ||
|
|
f7eecafb45 | ||
|
|
6e6cf9ee96 | ||
|
|
5b628e23e3 | ||
|
|
4a4dc8eb0f | ||
|
|
a33becad35 | ||
|
|
d60e8b6ce3 | ||
|
|
072c27fcd5 | ||
|
|
194dcc4571 | ||
|
|
51e3cfaf32 | ||
|
|
c8ab3801ec | ||
|
|
731e6dd8a2 | ||
|
|
d7066f4a2d | ||
|
|
5274de83d6 | ||
|
|
7e4dac7f31 | ||
|
|
28122dba18 | ||
|
|
93cde61946 | ||
|
|
c2dc06a99c | ||
|
|
c9930b7fda | ||
|
|
d1149454a8 | ||
|
|
36139f6200 | ||
|
|
15ac1be92b | ||
|
|
8ca30ed32c | ||
|
|
e2605bc7c2 | ||
|
|
b8608d0976 | ||
|
|
fc1ca40af4 | ||
|
|
f454f6e4d1 | ||
|
|
8cc7073aec | ||
|
|
031387a471 | ||
|
|
dd3567c39d | ||
|
|
ad2b8891a5 | ||
|
|
f6e18c65cf | ||
|
|
1c4a53a37a | ||
|
|
bc2db187aa | ||
|
|
92b4927a80 | ||
|
|
3cbabe012e | ||
|
|
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 | ||
|
|
e000e17856 | ||
|
|
1be8f98c46 | ||
|
|
6e476e4756 | ||
|
|
e2d36a3a7d | ||
|
|
a97ebe54f1 | ||
|
|
b2cc0dfe59 | ||
|
|
af99d4ebd3 | ||
|
|
a0a072dcdd | ||
|
|
447c7aac06 | ||
|
|
752615f231 | ||
|
|
f9b365bc7f | ||
|
|
17a1a9093a | ||
|
|
8159e98075 | ||
|
|
6a527b3e75 | ||
|
|
ebe689e85c | ||
|
|
8b683b44e6 | ||
|
|
725ca574f6 | ||
|
|
1dc39e2484 | ||
|
|
090556691c | ||
|
|
8e9f781cdb | ||
|
|
9d7c205442 | ||
|
|
902379fa3e | ||
|
|
94dcf37add | ||
|
|
843a5e2334 | ||
|
|
cdaad392ae | ||
|
|
49abe0d594 | ||
|
|
eeb33ae9c9 | ||
|
|
55fa1bb7cc | ||
|
|
90bbbb7993 | ||
|
|
fc9705665c | ||
|
|
52c477e118 | ||
|
|
41a4573002 | ||
|
|
f813fb5884 | ||
|
|
429c001412 | ||
|
|
cd7743cbc1 | ||
|
|
cadc3f96de | ||
|
|
a74515d6e1 | ||
|
|
0df9af0d75 | ||
|
|
e653c1a833 | ||
|
|
8b819e1db4 | ||
|
|
5236bfdf07 | ||
|
|
7e93eb81ea | ||
|
|
abf1555a65 | ||
|
|
584db77fee | ||
|
|
3faa91d670 | ||
|
|
2bf8ef86b9 | ||
|
|
624a4fe320 | ||
|
|
5508b5e4ec | ||
|
|
3af2ba80e8 | ||
|
|
40a00278ab | ||
|
|
5ab89f3ae3 | ||
|
|
40dc195fad | ||
|
|
ca374ad0a0 | ||
|
|
bc9f556c38 | ||
|
|
d06e54d2bb | ||
|
|
65ddce2886 | ||
|
|
f413598ba3 | ||
|
|
34c04d5f01 | ||
|
|
0029b3b6f7 | ||
|
|
75cc35272a | ||
|
|
821ac7ee4d | ||
|
|
8df46c4ded | ||
|
|
2303ce2372 | ||
|
|
cf672a9575 | ||
|
|
5ee4567353 | ||
|
|
6f72dba9c4 | ||
|
|
cd717896fa | ||
|
|
d47562cc08 | ||
|
|
aff5d7f0c1 | ||
|
|
4c0be13dcc | ||
|
|
afa410e47f | ||
|
|
7ee55e8bd3 | ||
|
|
e3c9a3f9dc | ||
|
|
aa841e35cf | ||
|
|
598fb2f55a | ||
|
|
aa786280a3 | ||
|
|
07fd9d0ea4 | ||
|
|
6ad0590d87 | ||
|
|
ff51e9ca3c | ||
|
|
7958cc6a33 | ||
|
|
2871e15418 | ||
|
|
c7d57548e5 | ||
|
|
dac13c90f2 | ||
|
|
50d8b764a9 | ||
|
|
90fc0b2dff | ||
|
|
d9d43d5b3b | ||
|
|
ce2c3ca688 | ||
|
|
3bd1bbe858 | ||
|
|
448fb0bc06 | ||
|
|
09a84892d9 | ||
|
|
16da5f2a56 | ||
|
|
f5f2a02eda | ||
|
|
dad006aa1e | ||
|
|
20a94ddb77 | ||
|
|
ae733168cd | ||
|
|
49616d2e16 | ||
|
|
7bc47bb858 | ||
|
|
e149aac756 | ||
|
|
62abc6be99 | ||
|
|
0072c9801f | ||
|
|
729708ad2e | ||
|
|
a4c6504c96 | ||
|
|
1bd5f3d7c8 | ||
|
|
3d6ea09c50 | ||
|
|
f45b8f3066 | ||
|
|
ad99fc9a73 | ||
|
|
da05ee321c | ||
|
|
de497a36bb | ||
|
|
882a9cb145 | ||
|
|
65cb5b49ff | ||
|
|
ae6f2e67aa | ||
|
|
d13d42d434 | ||
|
|
67c99dd33c | ||
|
|
ce012faf1e | ||
|
|
554a1a69a7 | ||
|
|
e489552614 | ||
|
|
8386198073 | ||
|
|
9c570f1435 | ||
|
|
ed2d9a6728 | ||
|
|
f336513d18 | ||
|
|
5755d6aa1f | ||
|
|
e363e343b7 | ||
|
|
fe955258c3 | ||
|
|
f5ac4a0d2a | ||
|
|
97ed7eb35c | ||
|
|
6909385aea | ||
|
|
8dabe113fa | ||
|
|
f5556230f1 | ||
|
|
327551f8b6 | ||
|
|
cdb4a4547f | ||
|
|
52f72b9cf7 | ||
|
|
048dac9359 | ||
|
|
f93d44730e | ||
|
|
21c64cb561 | ||
|
|
7e405f1568 | ||
|
|
04248d752e | ||
|
|
15f6a0fe49 | ||
|
|
54d254100d | ||
|
|
6d7f3479d1 | ||
|
|
5e1fca8b4e | ||
|
|
30229bfd14 | ||
|
|
1ce9c2dd6a | ||
|
|
5edf9c95b7 | ||
|
|
35657b4d5f | ||
|
|
b7786c4b42 | ||
|
|
6ca3784b67 | ||
|
|
c1b92c3175 | ||
|
|
b7afa9ba73 | ||
|
|
119d534e85 | ||
|
|
4e416dea5e | ||
|
|
b600dd7091 | ||
|
|
d59e5be214 | ||
|
|
cddbc1f3ff | ||
|
|
7c0d413e3c | ||
|
|
00de8addfe | ||
|
|
869e7bb17f | ||
|
|
8491d4207a | ||
|
|
6f31c22f11 | ||
|
|
a4c806ec26 | ||
|
|
1445fb769d | ||
|
|
a6bdaa6776 | ||
|
|
b44ca370f1 | ||
|
|
d2776903c2 | ||
|
|
936ee754b1 | ||
|
|
3a431547bb | ||
|
|
066e1e9abd | ||
|
|
b19fa88dec | ||
|
|
3aa50ddc9d | ||
|
|
f26db39334 | ||
|
|
3003e9e67a | ||
|
|
ee1303198a | ||
|
|
acb92a8df9 | ||
|
|
78410f9738 | ||
|
|
edf571debd | ||
|
|
257448bead | ||
|
|
e9f4cfb010 | ||
|
|
3c5f594b94 | ||
|
|
cf3f9f134a | ||
|
|
a643dc6ab5 | ||
|
|
8c705f8af9 | ||
|
|
28104c684d | ||
|
|
eccf6a2522 | ||
|
|
8ba3d50d7d | ||
|
|
75031d4461 | ||
|
|
13e64ea341 | ||
|
|
6034ffcd3c | ||
|
|
a749633d51 | ||
|
|
dad8a74ab4 | ||
|
|
3f03d30ce5 | ||
|
|
3365f4ebb2 | ||
|
|
68b8932406 | ||
|
|
a24351838a | ||
|
|
67b1083f3a | ||
|
|
aa4a97730a | ||
|
|
02f34a3b31 | ||
|
|
7cebd74ce5 | ||
|
|
bd1d93bbbc | ||
|
|
37ff1a3d12 | ||
|
|
308ebdbd3b | ||
|
|
cb6e798b90 | ||
|
|
2224411358 | ||
|
|
586f09a0c0 | ||
|
|
475ae744e6 | ||
|
|
553b6454f1 | ||
|
|
5805be2a2a | ||
|
|
c44d89a742 | ||
|
|
ce3fc7625a | ||
|
|
2532aeaa1d | ||
|
|
a4dafd1b32 | ||
|
|
07c87ff431 | ||
|
|
4a4b379f21 | ||
|
|
837e2bcc93 | ||
|
|
33dc7ea273 | ||
|
|
b8da7ce8b8 | ||
|
|
da3c255575 | ||
|
|
5eaad6ea6c | ||
|
|
f68e240293 | ||
|
|
ddc68e480a | ||
|
|
c31ebfaf6b | ||
|
|
e79f6e74bb | ||
|
|
57f9d875be | ||
|
|
a59144d8e1 | ||
|
|
3d8837bddb | ||
|
|
4d89b13e6f | ||
|
|
7d9689ba01 | ||
|
|
81a1dde380 | ||
|
|
8448acdf90 | ||
|
|
51b867e5dc | ||
|
|
c97ab6ee18 | ||
|
|
9584703534 | ||
|
|
f31ed81983 | ||
|
|
e85a698821 | ||
|
|
2bb09a50a1 | ||
|
|
17ed9baedb | ||
|
|
b40685050d | ||
|
|
0dab4f92ac | ||
|
|
0ddc3503a5 | ||
|
|
29ea3a7d67 | ||
|
|
823a1a42b4 | ||
|
|
0b66b1c473 | ||
|
|
934afbcf15 | ||
|
|
e0c924d2f4 | ||
|
|
a92b56491e | ||
|
|
6dcd9f4fef | ||
|
|
f5c36e520e | ||
|
|
fb2a3433e2 | ||
|
|
87ddddf41e | ||
|
|
45c8c1b873 | ||
|
|
68c9ada079 | ||
|
|
16a0473b10 | ||
|
|
4210d1ab05 | ||
|
|
bb7cf2c425 | ||
|
|
1a67379f57 | ||
|
|
d99fcf4a1c | ||
|
|
9954e2b014 | ||
|
|
7d28dfdb15 | ||
|
|
fd9c64f416 | ||
|
|
7e766f4714 | ||
|
|
9dc307f7cc | ||
|
|
47c0330610 | ||
|
|
ded8a4086f | ||
|
|
f6dc35c9a1 | ||
|
|
408d15f73f | ||
|
|
a50f6795cc | ||
|
|
da10396f2b | ||
|
|
c2f28e3ef5 | ||
|
|
40ab22fded | ||
|
|
db0482ed1f | ||
|
|
9f8a877e8f | ||
|
|
331fe02c2a | ||
|
|
34ee2a17c2 | ||
|
|
8dbb61cdd4 | ||
|
|
cc86750dfb | ||
|
|
646f884e8f | ||
|
|
fbdf6c02c1 | ||
|
|
8ab757be8d | ||
|
|
b557df5840 | ||
|
|
8271bb7f40 | ||
|
|
0008c487e9 | ||
|
|
ae68204542 | ||
|
|
e68e081d7f | ||
|
|
006dc8f9d0 | ||
|
|
b9b30a23e6 | ||
|
|
8af3cfd031 | ||
|
|
fae5e78292 | ||
|
|
a13adf8f3a | ||
|
|
dc89adf190 | ||
|
|
3fc793287b | ||
|
|
7d4dd65acd | ||
|
|
d4a2a5fe80 | ||
|
|
d9a01999e8 | ||
|
|
6bb4060686 | ||
|
|
605246f93b | ||
|
|
5ef76dece1 | ||
|
|
d24d2e6fd0 | ||
|
|
9546f3ad1e | ||
|
|
6d2c00376e | ||
|
|
117136ada3 | ||
|
|
f130d4d8c7 | ||
|
|
a46eea77a6 | ||
|
|
0b42927d38 | ||
|
|
b979ca6e79 | ||
|
|
b57e192965 | ||
|
|
91ce4cc854 | ||
|
|
b58195e154 | ||
|
|
0d3f6a0812 | ||
|
|
22ce4605d8 | ||
|
|
474d461b1c | ||
|
|
339de90b8a | ||
|
|
048313aefa | ||
|
|
805f73582f | ||
|
|
a6ab998e5c | ||
|
|
92075c8426 | ||
|
|
751c520865 | ||
|
|
4fa2ef66ed | ||
|
|
aa735ef562 | ||
|
|
a927f9c458 | ||
|
|
82f03d0f1d | ||
|
|
40aca13fb0 | ||
|
|
e2832bac7c | ||
|
|
5f96916701 | ||
|
|
4a96d95bde | ||
|
|
dc3eb9081a | ||
|
|
2ddd920e4d | ||
|
|
e1e9940a04 | ||
|
|
bc8626c6f8 | ||
|
|
f2817b7c49 | ||
|
|
fc79e82108 | ||
|
|
58052634fa | ||
|
|
c1374c5592 | ||
|
|
3a5d636345 | ||
|
|
642d802eee | ||
|
|
76ba624dce | ||
|
|
01d5f034b9 | ||
|
|
b7ced9f289 | ||
|
|
1df2bf4d9b | ||
|
|
9c39e1ddc9 | ||
|
|
bbac1cb663 | ||
|
|
3bc23a60c7 | ||
|
|
c171ae4761 | ||
|
|
b530f4928e | ||
|
|
9fc6afd181 | ||
|
|
eb3d88afbf | ||
|
|
8ded0658d4 | ||
|
|
d032f81fd6 | ||
|
|
75becccf78 | ||
|
|
bfb84e2960 | ||
|
|
2f64c5a407 | ||
|
|
9e6765f4e2 | ||
|
|
d77c84b7c9 | ||
|
|
7172f787ac | ||
|
|
77f66d5357 | ||
|
|
4307815c0f | ||
|
|
b484f36a22 | ||
|
|
360a649d2a | ||
|
|
4bdd23291c | ||
|
|
1ee480122b | ||
|
|
382a5220e0 | ||
|
|
627155b13c | ||
|
|
0ca4dd1b32 | ||
|
|
383bcc4fc1 | ||
|
|
8028a0d75d | ||
|
|
31c1c5eb56 | ||
|
|
ca9295016a | ||
|
|
7b90fe9833 | ||
|
|
330786352c | ||
|
|
da309ae929 | ||
|
|
3877ae5328 | ||
|
|
9c99070d54 | ||
|
|
5751250a7f | ||
|
|
f97ff8124c | ||
|
|
7f37ed96d1 | ||
|
|
7ff7049018 | ||
|
|
ae895787c1 | ||
|
|
305f5c7d47 | ||
|
|
f0d3984376 | ||
|
|
b8f355ac4f | ||
|
|
29195a17a7 | ||
|
|
e90efd4fa9 | ||
|
|
70c158990d | ||
|
|
6e2efb4b55 | ||
|
|
18db1a78ab | ||
|
|
bd87d729fd | ||
|
|
a6f3ef591f | ||
|
|
aaed0a186c | ||
|
|
2b74bbca5c | ||
|
|
36758f61b4 | ||
|
|
f9de81f032 | ||
|
|
444aa53803 | ||
|
|
4e9f3e6e05 | ||
|
|
3fc4482d7c | ||
|
|
ad851375dd | ||
|
|
dd3e942057 | ||
|
|
dd4439c34a | ||
|
|
e81d850438 | ||
|
|
779453be26 | ||
|
|
867d341182 | ||
|
|
78b51f9a11 | ||
|
|
417772dd9c | ||
|
|
b86885512f | ||
|
|
65381cd614 | ||
|
|
e3f11d8fe1 | ||
|
|
093eb419a8 | ||
|
|
ad25490cf9 | ||
|
|
e94d3d80fa | ||
|
|
0e6b6d90bc | ||
|
|
ce1f9259bf | ||
|
|
8cec12b98b | ||
|
|
028b1b2d93 | ||
|
|
d8e5d0d4f1 | ||
|
|
0da9826821 | ||
|
|
b416e09e8b | ||
|
|
2ef58bd75d | ||
|
|
cac794208e | ||
|
|
a22aec8694 | ||
|
|
6f11e820b5 | ||
|
|
6a75b60b4f | ||
|
|
c494adbd87 | ||
|
|
3aa0f13869 | ||
|
|
e9af9efaf3 | ||
|
|
1ecc8066db | ||
|
|
525775fb36 | ||
|
|
3e4c608aca | ||
|
|
bd5028e331 | ||
|
|
4207614600 | ||
|
|
e1af950724 | ||
|
|
e91388a010 | ||
|
|
8648ddb528 | ||
|
|
feff5b6a57 | ||
|
|
32198910ce | ||
|
|
07f7147ff3 | ||
|
|
0e8a006adc | ||
|
|
4193e3c78f | ||
|
|
d2f91d6f1e | ||
|
|
9ed5093474 | ||
|
|
a12dd019d3 | ||
|
|
e53bc92f41 | ||
|
|
597a8db2d9 | ||
|
|
0b0f639cd0 | ||
|
|
6b4ba6bca1 | ||
|
|
3cdd67b5b4 | ||
|
|
2b03bc7a53 | ||
|
|
f6447b8936 | ||
|
|
4069c33671 | ||
|
|
4dbc5581a5 | ||
|
|
3fc5c1c995 | ||
|
|
f123057ae5 | ||
|
|
5025ec818f | ||
|
|
65c6c8146d | ||
|
|
b93745f24a | ||
|
|
bbb59c9fe1 | ||
|
|
fb56f6571a | ||
|
|
082af1f82c | ||
|
|
f5b3a0cbad | ||
|
|
d602ccc224 | ||
|
|
ab54e7b7bd | ||
|
|
0137b07f66 | ||
|
|
ae95fbd1be | ||
|
|
625bd97d8b | ||
|
|
603fce7cd3 | ||
|
|
c759f460d5 | ||
|
|
e1c00667f7 | ||
|
|
46631fc4d4 | ||
|
|
f235a5d951 | ||
|
|
b0b711f181 | ||
|
|
f1b7102583 | ||
|
|
f076420e68 | ||
|
|
4ce0a7eaa1 | ||
|
|
270b730c21 | ||
|
|
14a6cc5999 | ||
|
|
0067873f60 | ||
|
|
fc16e1461f | ||
|
|
8f01916bb2 | ||
|
|
87d522f249 | ||
|
|
ead138b4cd | ||
|
|
ef39979548 | ||
|
|
ab6c8caf2f | ||
|
|
ba9206bded | ||
|
|
c5838cf955 | ||
|
|
0c41fc30e4 | ||
|
|
8270cfa39e | ||
|
|
06e356cab9 | ||
|
|
f24631dc48 | ||
|
|
ec936bcd06 | ||
|
|
6c7975e400 | ||
|
|
8acef7ab66 | ||
|
|
4ddfd39b0d | ||
|
|
3ba37cc24c | ||
|
|
da080118b0 | ||
|
|
e8897ea80a | ||
|
|
9d26f582fa | ||
|
|
0a2ad2176c | ||
|
|
7c36439836 | ||
|
|
a1e3afed06 | ||
|
|
be5b1bbae3 | ||
|
|
52de23bf95 | ||
|
|
1d16fe46cd | ||
|
|
cbecda13f9 | ||
|
|
24649ec103 | ||
|
|
b9f62a8399 | ||
|
|
689d45e095 | ||
|
|
33f7b3f2bb | ||
|
|
2cce072393 | ||
|
|
d64bdec2f9 | ||
|
|
5adacdecad | ||
|
|
f2552d3f3b | ||
|
|
b435e01199 | ||
|
|
cd576df6e4 | ||
|
|
9fbd3b1a72 | ||
|
|
c6dd35a092 | ||
|
|
470c6f42b0 | ||
|
|
e6956e45ac | ||
|
|
a2b9dedbb7 | ||
|
|
384129609f | ||
|
|
5be0811f01 | ||
|
|
1a10b4671a | ||
|
|
e7d36b7e21 | ||
|
|
290fac9115 | ||
|
|
0633333db4 | ||
|
|
630323ad47 | ||
|
|
51fc7f5457 | ||
|
|
44a9b3bd12 | ||
|
|
2b879a5ec8 | ||
|
|
381f2ef165 | ||
|
|
825ebca3f0 | ||
|
|
e0063070a6 | ||
|
|
013ae51d10 | ||
|
|
08e54510ed | ||
|
|
a95606cee9 | ||
|
|
c990476d99 | ||
|
|
44c8f02d06 | ||
|
|
a012542015 | ||
|
|
926df433a7 | ||
|
|
5c09e0d8fa | ||
|
|
dba86053f5 | ||
|
|
cd188b267d | ||
|
|
8a09fbaac9 | ||
|
|
9676c8f4ee | ||
|
|
9b5aeab889 | ||
|
|
28ebf1b88a | ||
|
|
f922e82f53 | ||
|
|
beec1e060e | ||
|
|
26a24d3d5c | ||
|
|
c3c7f03f04 | ||
|
|
4af29d6c20 | ||
|
|
b28f77f893 | ||
|
|
4d9720449f | ||
|
|
444f6889c8 | ||
|
|
c719d24f3a | ||
|
|
f746368369 | ||
|
|
164b6ff6c1 | ||
|
|
7ae522e610 | ||
|
|
e1a0ec3061 | ||
|
|
f8222528eb | ||
|
|
759003c781 | ||
|
|
6529256e98 | ||
|
|
b142bd2fd5 | ||
|
|
4a39de5c56 | ||
|
|
d53f7489ce | ||
|
|
dfe784b44a | ||
|
|
3dbe9e6a3e | ||
|
|
4fa8f9b4c2 | ||
|
|
24205601e1 | ||
|
|
31f8af8409 | ||
|
|
9317a93152 |
@@ -9,5 +9,6 @@
|
||||
"WebFetch(domain:github.com)"
|
||||
],
|
||||
"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).
|
||||
35
.github/collect-prerelease-info.js
vendored
Normal file
35
.github/collect-prerelease-info.js
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
// Collect the packages that a snapshot prerelease just published, so the
|
||||
// prerelease comment workflow can render an install line per package.
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const [, , prNumber, channel] = process.argv
|
||||
|
||||
const packagesDir = join(process.cwd(), "packages")
|
||||
const published = []
|
||||
|
||||
for (const dir of readdirSync(packagesDir)) {
|
||||
const pkgPath = join(packagesDir, dir, "package.json")
|
||||
if (!existsSync(pkgPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
|
||||
|
||||
// Snapshot versions are stamped `0.0.0-<channel>-<timestamp>`, so the channel
|
||||
// marker is how we tell which packages this run actually versioned.
|
||||
if (
|
||||
!pkg.private &&
|
||||
typeof pkg.version === "string" &&
|
||||
pkg.version.includes(`-${channel}`)
|
||||
) {
|
||||
published.push({ name: pkg.name, version: pkg.version })
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
"prerelease-info.json",
|
||||
JSON.stringify({ pr: prNumber, channel, packages: published }, null, 2)
|
||||
)
|
||||
|
||||
console.log(`Collected ${published.length} prerelease package(s).`)
|
||||
40
.github/dependabot.yml
vendored
40
.github/dependabot.yml
vendored
@@ -4,3 +4,43 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/astro-app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/astro-monorepo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/next-app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/next-monorepo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/react-router-app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/react-router-monorepo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/start-app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/start-monorepo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/vite-app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/vite-monorepo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
21
.github/version-script-beta.js
vendored
21
.github/version-script-beta.js
vendored
@@ -1,21 +0,0 @@
|
||||
// ORIGINALLY FROM CLOUDFLARE WRANGLER:
|
||||
// https://github.com/cloudflare/wrangler2/blob/main/.github/version-script.js
|
||||
|
||||
import { exec } from "child_process"
|
||||
import fs from "fs"
|
||||
|
||||
const pkgJsonPath = "packages/shadcn/package.json"
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
|
||||
exec("git rev-parse --short HEAD", (err, stdout) => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
process.exit(1)
|
||||
}
|
||||
pkg.version = "0.0.0-beta." + stdout.trim()
|
||||
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, "\t") + "\n")
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
56
.github/workflows/browser-tests.yml
vendored
Normal file
56
.github/workflows/browser-tests.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Browser tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
paths:
|
||||
- "packages/react/**"
|
||||
- ".github/workflows/browser-tests.yml"
|
||||
|
||||
jobs:
|
||||
browser:
|
||||
runs-on: ubuntu-latest
|
||||
name: pnpm test:browser
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 10.33.4
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm --filter=@shadcn/react exec playwright install --with-deps chromium
|
||||
|
||||
- run: pnpm --filter=@shadcn/react test:browser
|
||||
11
.github/workflows/code-check.yml
vendored
11
.github/workflows/code-check.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 9.0.6
|
||||
version: 10.33.4
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 9.0.6
|
||||
version: 10.33.4
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
@@ -77,6 +77,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm build:packages
|
||||
|
||||
- run: pnpm format:check
|
||||
|
||||
tsc:
|
||||
@@ -96,7 +99,7 @@ jobs:
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 9.0.6
|
||||
version: 10.33.4
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
@@ -114,6 +117,6 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm --filter=shadcn build
|
||||
run: pnpm build:packages
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
78
.github/workflows/deprecated.yml
vendored
78
.github/workflows/deprecated.yml
vendored
@@ -1,78 +0,0 @@
|
||||
name: Deprecated
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
deprecated:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: |
|
||||
apps/www/**
|
||||
files_ignore: |
|
||||
apps/www/public/r/**
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
sha: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Comment on PR if www files changed
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const changedFiles = `${{ steps.changed-files.outputs.all_changed_files }}`.split(' ');
|
||||
const wwwFiles = changedFiles.filter(file =>
|
||||
file.startsWith('apps/www/') &&
|
||||
!file.startsWith('apps/www/public/r/') &&
|
||||
file !== 'apps/www/package.json'
|
||||
);
|
||||
|
||||
if (wwwFiles.length > 0) {
|
||||
const comment = `Looks like this PR modifies files in \`apps/www\`, which is deprecated.
|
||||
|
||||
Consider applying the change to \`apps/v4\` if relevant.`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
// Add deprecated label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['deprecated']
|
||||
});
|
||||
} else {
|
||||
// Remove deprecated label if no www files are changed
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'deprecated'
|
||||
});
|
||||
} catch (error) {
|
||||
// Label doesn't exist, which is fine
|
||||
console.log('Deprecated label not found, skipping removal');
|
||||
}
|
||||
}
|
||||
107
.github/workflows/prerelease-comment.yml
vendored
107
.github/workflows/prerelease-comment.yml
vendored
@@ -1,9 +1,9 @@
|
||||
# Adapted from create-t3-app.
|
||||
name: Write Beta Release comment
|
||||
name: Write Prerelease comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release - Beta"]
|
||||
workflows: ["Release"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
@@ -11,55 +11,84 @@ jobs:
|
||||
comment:
|
||||
if: |
|
||||
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
|
||||
name: Write comment to the PR
|
||||
steps:
|
||||
- name: "Comment on PR"
|
||||
uses: actions/github-script@v6
|
||||
# Stable pushes and no-changeset runs upload no artifact, so a missing
|
||||
# download is expected — gate the rest of the job on it succeeding.
|
||||
- name: Download prerelease info
|
||||
id: download
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: prerelease-info
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build comment
|
||||
id: info
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
const fs = require("fs");
|
||||
const info = JSON.parse(fs.readFileSync("prerelease-info.json", "utf8"));
|
||||
|
||||
for (const artifact of allArtifacts.data.artifacts) {
|
||||
// Extract the PR number and package version from the artifact name
|
||||
const match = /^npm-package-shadcn@(.*?)-pr-(\d+)/.exec(artifact.name);
|
||||
|
||||
if (match) {
|
||||
require("fs").appendFileSync(
|
||||
process.env.GITHUB_ENV,
|
||||
`\nBETA_PACKAGE_VERSION=${match[1]}` +
|
||||
`\nWORKFLOW_RUN_PR=${match[2]}` +
|
||||
`\nWORKFLOW_RUN_ID=${context.payload.workflow_run.id}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (!info.packages || info.packages.length === 0) {
|
||||
core.info("No prerelease packages to comment.");
|
||||
return;
|
||||
}
|
||||
|
||||
- name: "Comment on PR with Link"
|
||||
const installs = info.packages
|
||||
.map((p) => `pnpm dlx ${p.name}@${p.version}`)
|
||||
.join("\n");
|
||||
const links = info.packages
|
||||
.map(
|
||||
(p) =>
|
||||
`- [${p.name}@${p.version}](https://www.npmjs.com/package/${p.name}/v/${p.version})`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const body = [
|
||||
`A new ${info.channel} prerelease is available for testing:`,
|
||||
"",
|
||||
"```sh",
|
||||
installs,
|
||||
"```",
|
||||
"",
|
||||
links,
|
||||
].join("\n");
|
||||
|
||||
core.setOutput("pr", info.pr);
|
||||
core.setOutput("channel", info.channel);
|
||||
core.setOutput("body", body);
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.info.outputs.body
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
number: ${{ env.WORKFLOW_RUN_PR }}
|
||||
message: |
|
||||
A new prerelease is available for testing:
|
||||
number: ${{ steps.info.outputs.pr }}
|
||||
message: ${{ steps.info.outputs.body }}
|
||||
|
||||
```sh
|
||||
pnpm dlx shadcn@${{ env.BETA_PACKAGE_VERSION }}
|
||||
```
|
||||
|
||||
- name: "Remove the autorelease label once published"
|
||||
uses: actions/github-script@v6
|
||||
- name: Remove the prerelease label once published
|
||||
if: steps.info.outputs.pr
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: '${{ env.WORKFLOW_RUN_PR }}',
|
||||
name: '🚀 autorelease',
|
||||
});
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: Number("${{ steps.info.outputs.pr }}"),
|
||||
name: `release: ${{ steps.info.outputs.channel }}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
core.info("The prerelease label was already removed.");
|
||||
}
|
||||
|
||||
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
|
||||
145
.github/workflows/release.yml
vendored
145
.github/workflows/release.yml
vendored
@@ -2,31 +2,63 @@
|
||||
|
||||
name: Release
|
||||
|
||||
run-name: ${{ github.event_name == 'pull_request' && format('Release Prerelease - PR {0}', github.event.number) || 'Release Stable' }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: ${{ github.repository_owner == 'shadcn-ui' }}
|
||||
name: Create a PR for release workflow
|
||||
prerelease:
|
||||
if: "${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && (contains(github.event.pull_request.labels.*.name, 'release: beta') || contains(github.event.pull_request.labels.*.name, 'release: rc')) }}"
|
||||
name: Publish Prerelease to NPM
|
||||
runs-on: ubuntu-latest
|
||||
environment: Preview
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Select prerelease channel
|
||||
id: prerelease
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prereleaseLabels = [
|
||||
{ name: "release: beta", channel: "beta" },
|
||||
{ name: "release: rc", channel: "rc" },
|
||||
];
|
||||
const labels = context.payload.pull_request.labels.map((label) => label.name);
|
||||
const selectedLabels = prereleaseLabels.filter((label) =>
|
||||
labels.includes(label.name)
|
||||
);
|
||||
|
||||
if (selectedLabels.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected exactly one prerelease label, found: ${
|
||||
selectedLabels.map((label) => label.name).join(", ") || "none"
|
||||
}.`
|
||||
);
|
||||
}
|
||||
|
||||
core.setOutput("channel", selectedLabels[0].channel);
|
||||
core.setOutput("label", selectedLabels[0].name);
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Use PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.0.6
|
||||
version: 10.33.4
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
@@ -41,16 +73,101 @@ jobs:
|
||||
- name: Install NPM Dependencies
|
||||
run: pnpm install
|
||||
|
||||
# - name: Check for errors
|
||||
# run: pnpm check
|
||||
# A snapshot prerelease needs changesets to compute versions. The
|
||||
# Changesets version PR consumes them, so a label on that PR is a no-op.
|
||||
- name: Check for changesets
|
||||
id: changesets
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
present=false
|
||||
for file in .changeset/*.md; do
|
||||
if [ "$(basename "$file")" != "README.md" ]; then
|
||||
present=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "present=$present" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build the package
|
||||
run: pnpm shadcn:build
|
||||
- name: No changesets to prerelease
|
||||
if: steps.changesets.outputs.present == 'false'
|
||||
run: echo "::notice::No changesets found on this branch; nothing to prerelease."
|
||||
|
||||
# Snapshot versions are stamped per run (timestamped), so each publish is
|
||||
# unique and can never collide with a real release on the latest tag.
|
||||
- name: Version snapshot
|
||||
if: steps.changesets.outputs.present == 'true'
|
||||
run: pnpm exec changeset version --snapshot ${{ steps.prerelease.outputs.channel }}
|
||||
|
||||
- name: Build packages
|
||||
if: steps.changesets.outputs.present == 'true'
|
||||
run: pnpm build:packages
|
||||
|
||||
- name: Publish snapshot to NPM
|
||||
if: steps.changesets.outputs.present == 'true'
|
||||
run: pnpm exec changeset publish --tag ${{ steps.prerelease.outputs.channel }} --no-git-tag
|
||||
|
||||
- name: Collect prerelease info
|
||||
if: steps.changesets.outputs.present == 'true'
|
||||
run: node .github/collect-prerelease-info.js "${{ github.event.number }}" "${{ steps.prerelease.outputs.channel }}"
|
||||
|
||||
- name: Upload prerelease info
|
||||
if: steps.changesets.outputs.present == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: prerelease-info
|
||||
path: prerelease-info.json
|
||||
|
||||
release:
|
||||
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:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.4
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Update npm for OIDC support
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
run: pnpm install
|
||||
|
||||
# Builds every publishable package under packages/* (shadcn, @shadcn/react),
|
||||
# never apps/v4, so each dist is fresh before changeset publish.
|
||||
- name: Build the packages
|
||||
run: pnpm build:packages
|
||||
|
||||
- name: Import GPG key
|
||||
uses: crazy-max/ghaction-import-gpg@v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
git_user_signingkey: true
|
||||
git_commit_gpgsign: true
|
||||
git_tag_gpgsign: true
|
||||
|
||||
- name: Create Version PR or Publish to NPM
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
setupGitUser: false
|
||||
commit: "chore(release): version packages"
|
||||
title: "chore(release): version packages"
|
||||
version: node .github/changeset-version.js
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
314
.github/workflows/templates.yml
vendored
Normal file
314
.github/workflows/templates.yml
vendored
Normal file
@@ -0,0 +1,314 @@
|
||||
name: Templates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
paths:
|
||||
- ".github/workflows/templates.yml"
|
||||
- "apps/v4/registry/**"
|
||||
- "package.json"
|
||||
- "packages/shadcn/src/commands/add.ts"
|
||||
- "packages/shadcn/src/commands/init.ts"
|
||||
- "packages/shadcn/src/templates/**"
|
||||
- "packages/shadcn/src/utils/create-project.ts"
|
||||
- "packages/shadcn/src/utils/get-monorepo-info.ts"
|
||||
- "packages/shadcn/src/utils/get-package-manager.ts"
|
||||
- "pnpm-lock.yaml"
|
||||
- "templates/**"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
name: ${{ matrix.package-manager == 'pnpm' && format('pnpm {0}', matrix.pnpm-version) || matrix.package-manager }} ${{ matrix.template }}
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
template: [next, vite, astro, start, react-router]
|
||||
package-manager: [pnpm, bun, npm, yarn]
|
||||
pnpm-version: [10.33.4, 11]
|
||||
exclude:
|
||||
- package-manager: bun
|
||||
pnpm-version: 11
|
||||
- package-manager: npm
|
||||
pnpm-version: 11
|
||||
- package-manager: yarn
|
||||
pnpm-version: 11
|
||||
env:
|
||||
NEXT_PUBLIC_APP_URL: http://localhost:4000
|
||||
NEXT_PUBLIC_V0_URL: https://v0.dev
|
||||
REGISTRY_URL: http://localhost:4000/r
|
||||
ROOT_PNPM_VERSION: 10.33.4
|
||||
TEMPLATE_PNPM_VERSION: ${{ matrix.pnpm-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: ${{ env.ROOT_PNPM_VERSION }}
|
||||
run_install: false
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Yarn
|
||||
if: matrix.package-manager == 'yarn'
|
||||
run: |
|
||||
corepack enable
|
||||
COREPACK_ENABLE_PROJECT_SPEC=0 corepack prepare yarn@4.12.0 --activate
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
pnpm --filter=shadcn build
|
||||
pnpm --filter=v4 registry:build
|
||||
|
||||
- name: Validate templates
|
||||
env:
|
||||
TEMPLATE: ${{ matrix.template }}
|
||||
TEMPLATE_PACKAGE_MANAGER: ${{ matrix.package-manager }}
|
||||
SHADCN_TEMPLATE_DIR: ${{ github.workspace }}/templates
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
root_pnpm="$(command -v pnpm)"
|
||||
validation_script="$RUNNER_TEMP/validate-templates.sh"
|
||||
|
||||
cat > "$validation_script" <<'BASH'
|
||||
set -euo pipefail
|
||||
|
||||
bin_dir="$RUNNER_TEMP/template-pnpm-bin"
|
||||
mkdir -p "$bin_dir"
|
||||
|
||||
cat > "$bin_dir/pnpm" <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec npx -y "pnpm@${TEMPLATE_PNPM_VERSION}" "$@"
|
||||
PNPM
|
||||
chmod +x "$bin_dir/pnpm"
|
||||
export PATH="$bin_dir:$PATH"
|
||||
|
||||
echo "Using template pnpm $(pnpm --version)"
|
||||
|
||||
cli="$GITHUB_WORKSPACE/packages/shadcn/dist/index.js"
|
||||
template_root="$RUNNER_TEMP/generated-template-${TEMPLATE_PACKAGE_MANAGER}-${TEMPLATE}"
|
||||
rm -rf "$template_root"
|
||||
mkdir -p "$template_root"
|
||||
|
||||
modes=(app monorepo)
|
||||
|
||||
has_script() {
|
||||
node -e "const pkg = require('./package.json'); process.exit(pkg.scripts && pkg.scripts[process.argv[1]] ? 0 : 1)" "$1"
|
||||
}
|
||||
|
||||
run_script_if_present() {
|
||||
local script="$1"
|
||||
if has_script "$script"; then
|
||||
pnpm run "$script"
|
||||
else
|
||||
echo "No $script script found; skipping."
|
||||
fi
|
||||
}
|
||||
|
||||
validate_non_pnpm_project() {
|
||||
local package_manager="$1"
|
||||
local project_path="$2"
|
||||
local check_workspace_protocol="$3"
|
||||
local is_monorepo="$4"
|
||||
|
||||
cd "$project_path"
|
||||
test ! -f pnpm-workspace.yaml
|
||||
test ! -f pnpm-lock.yaml
|
||||
|
||||
EXPECTED_PACKAGE_MANAGER="$package_manager" \
|
||||
CHECK_WORKSPACE_PROTOCOL="$check_workspace_protocol" \
|
||||
IS_MONOREPO="$is_monorepo" \
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
|
||||
const expectedPackageManager = process.env.EXPECTED_PACKAGE_MANAGER
|
||||
const checkWorkspaceProtocol =
|
||||
process.env.CHECK_WORKSPACE_PROTOCOL === "true"
|
||||
const isMonorepo = process.env.IS_MONOREPO === "true"
|
||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||
|
||||
if (isMonorepo) {
|
||||
const workspaces = pkg.workspaces ?? []
|
||||
|
||||
if (!Array.isArray(workspaces)) {
|
||||
throw new Error("Expected package.json workspaces to be an array.")
|
||||
}
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
throw new Error("Expected package.json workspaces to have entries.")
|
||||
}
|
||||
|
||||
for (const workspace of ["sharp", "unrs-resolver", "esbuild"]) {
|
||||
if (workspaces.includes(workspace)) {
|
||||
throw new Error(`Unexpected workspace entry: ${workspace}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!pkg.packageManager?.startsWith(`${expectedPackageManager}@`)) {
|
||||
throw new Error(
|
||||
`Expected packageManager to use ${expectedPackageManager}, got ${pkg.packageManager}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (pkg.workspaces !== undefined) {
|
||||
throw new Error("Did not expect package.json workspaces for app template.")
|
||||
}
|
||||
|
||||
if (pkg.packageManager !== undefined) {
|
||||
throw new Error(
|
||||
`Did not expect packageManager for app template, got ${pkg.packageManager}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (checkWorkspaceProtocol) {
|
||||
const packageJsonFiles = []
|
||||
function collectPackageJsonFiles(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules") {
|
||||
continue
|
||||
}
|
||||
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
collectPackageJsonFiles(fullPath)
|
||||
} else if (entry.name === "package.json") {
|
||||
packageJsonFiles.push(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectPackageJsonFiles(process.cwd())
|
||||
|
||||
for (const file of packageJsonFiles) {
|
||||
const json = fs.readFileSync(file, "utf8")
|
||||
if (json.includes("workspace:")) {
|
||||
throw new Error(`Unexpected workspace: protocol in ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
for mode in "${modes[@]}"; do
|
||||
project="test-${TEMPLATE}-${mode}-${TEMPLATE_PACKAGE_MANAGER}"
|
||||
project_path="$template_root/$project"
|
||||
|
||||
echo "::group::${TEMPLATE} ${mode} ${TEMPLATE_PACKAGE_MANAGER}"
|
||||
args=(
|
||||
init
|
||||
--defaults
|
||||
--name "$project"
|
||||
--template "$TEMPLATE"
|
||||
--cwd "$template_root"
|
||||
--silent
|
||||
)
|
||||
|
||||
if [ "$mode" = "monorepo" ]; then
|
||||
args+=(--monorepo)
|
||||
is_monorepo="true"
|
||||
else
|
||||
args+=(--no-monorepo)
|
||||
is_monorepo="false"
|
||||
fi
|
||||
|
||||
case "$TEMPLATE_PACKAGE_MANAGER" in
|
||||
pnpm)
|
||||
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
|
||||
REGISTRY_URL="$REGISTRY_URL" \
|
||||
npm_config_user_agent="pnpm/${TEMPLATE_PNPM_VERSION}" \
|
||||
node "$cli" "${args[@]}"
|
||||
|
||||
cd "$project_path"
|
||||
pnpm install --frozen-lockfile
|
||||
run_script_if_present typecheck
|
||||
run_script_if_present build
|
||||
;;
|
||||
bun)
|
||||
(
|
||||
cd "$template_root"
|
||||
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
|
||||
REGISTRY_URL="$REGISTRY_URL" \
|
||||
npm_config_user_agent="bun/$(bun --version)" \
|
||||
bunx --bun --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
||||
shadcn "${args[@]}"
|
||||
)
|
||||
validate_non_pnpm_project \
|
||||
"bun" \
|
||||
"$project_path" \
|
||||
"false" \
|
||||
"$is_monorepo"
|
||||
;;
|
||||
npm)
|
||||
(
|
||||
cd "$template_root"
|
||||
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
|
||||
REGISTRY_URL="$REGISTRY_URL" \
|
||||
npm_config_user_agent="npm/$(npm --version)" \
|
||||
npx --yes --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
||||
shadcn "${args[@]}"
|
||||
)
|
||||
validate_non_pnpm_project \
|
||||
"npm" \
|
||||
"$project_path" \
|
||||
"true" \
|
||||
"$is_monorepo"
|
||||
;;
|
||||
yarn)
|
||||
(
|
||||
cd "$template_root"
|
||||
SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \
|
||||
REGISTRY_URL="$REGISTRY_URL" \
|
||||
COREPACK_ENABLE_PROJECT_SPEC=0 \
|
||||
npm_config_user_agent="yarn/$(COREPACK_ENABLE_PROJECT_SPEC=0 yarn --version)" \
|
||||
yarn dlx --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
||||
shadcn "${args[@]}"
|
||||
)
|
||||
validate_non_pnpm_project \
|
||||
"yarn" \
|
||||
"$project_path" \
|
||||
"false" \
|
||||
"$is_monorepo"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "::endgroup::"
|
||||
done
|
||||
BASH
|
||||
|
||||
"$root_pnpm" exec start-server-and-test \
|
||||
"$root_pnpm v4:dev" \
|
||||
http://localhost:4000 \
|
||||
"bash $validation_script"
|
||||
48
.github/workflows/test.yml
vendored
48
.github/workflows/test.yml
vendored
@@ -19,13 +19,52 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 9.0.6
|
||||
version: 10.33.4
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- run: pnpm test
|
||||
|
||||
react:
|
||||
runs-on: ubuntu-latest
|
||||
name: pnpm test (@shadcn/react)
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 10.33.4
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
@@ -42,7 +81,4 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm build --filter=shadcn
|
||||
|
||||
- run: pnpm test
|
||||
- run: pnpm --filter=@shadcn/react test
|
||||
|
||||
42
.github/workflows/validate-registries.yml
vendored
42
.github/workflows/validate-registries.yml
vendored
@@ -3,12 +3,12 @@ name: Validate Registries
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/v4/public/r/registries.json"
|
||||
- "apps/v4/registry/directory.json"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "apps/v4/public/r/registries.json"
|
||||
- "apps/v4/registry/directory.json"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
@@ -26,11 +26,44 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Block reserved registry namespaces
|
||||
env:
|
||||
RESERVED_NAMESPACES: "@shadcn,@ui,@blocks,@components,@block,@component,@util,@utils,@registry,@lib,@hook,@hooks,@theme,@themes,@chart,@charts"
|
||||
run: |
|
||||
node <<'EOF'
|
||||
const fs = require("node:fs")
|
||||
|
||||
const file = "apps/v4/registry/directory.json"
|
||||
const reservedNamespaces = new Set(
|
||||
process.env.RESERVED_NAMESPACES.split(",").filter(Boolean)
|
||||
)
|
||||
|
||||
function readNames() {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8")).map(
|
||||
(entry) => entry.name
|
||||
)
|
||||
}
|
||||
|
||||
const violations = readNames()
|
||||
.filter((name) => reservedNamespaces.has(name))
|
||||
.map((name) => `${file}: ${name}`)
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("Reserved registry namespaces are not allowed:")
|
||||
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation}`)
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
EOF
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 9.0.6
|
||||
version: 10.33.4
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
@@ -47,8 +80,5 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm build --filter=shadcn
|
||||
|
||||
- name: Validate registries
|
||||
run: pnpm --filter=v4 validate:registries
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -15,6 +15,7 @@ build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
@@ -41,3 +42,14 @@ tsconfig.tsbuildinfo
|
||||
.vscode
|
||||
|
||||
.notes
|
||||
.playwright-mcp
|
||||
.playwright-cli
|
||||
shadcn-workspace
|
||||
|
||||
# vitest browser mode writes these only on test failure.
|
||||
__screenshots__
|
||||
.codex-artifacts
|
||||
.tmp*
|
||||
|
||||
CONTEXT.md
|
||||
docs/adr
|
||||
|
||||
@@ -5,3 +5,4 @@ build
|
||||
.contentlayer
|
||||
**/fixtures
|
||||
deprecated
|
||||
apps/v4/registry/styles/**/*.css
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -15,6 +15,7 @@
|
||||
"search.exclude": {
|
||||
"apps/v4/registry/radix-*": true,
|
||||
"apps/v4/public/r/*": true,
|
||||
"packages/shadcn/test/fixtures/*": true
|
||||
"packages/shadcn/test/fixtures/*": true,
|
||||
"apps/v4/styles/*": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,11 @@ When adding or modifying components, please ensure that:
|
||||
2. You update the documentation.
|
||||
3. You run `pnpm registry:build` to update the registry.
|
||||
|
||||
See [`apps/v4/registry/README.md`](apps/v4/registry/README.md) for how the
|
||||
registry pipeline is structured and for the faster targeted build modes
|
||||
(`--style`, `--registry`, `--examples`, `--indexes`) you can use while
|
||||
iterating locally. Always run the full `pnpm registry:build` before committing.
|
||||
|
||||
## Commit Convention
|
||||
|
||||
Before you create a Pull Request, please check whether your commits comply with
|
||||
|
||||
@@ -6,7 +6,7 @@ A set of beautifully designed components that you can customize, extend, and bui
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit http://ui.shadcn.com/docs to view the documentation.
|
||||
Visit https://ui.shadcn.com/docs to view the documentation.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -14,4 +14,4 @@ Please read the [contributing guide](/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT license](https://github.com/shadcn/ui/blob/main/LICENSE.md).
|
||||
Licensed under the [MIT license](./LICENSE.md).
|
||||
|
||||
59
RELEASING.md
Normal file
59
RELEASING.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Releasing
|
||||
|
||||
This monorepo publishes two packages independently with [Changesets](https://github.com/changesets/changesets):
|
||||
|
||||
- **`shadcn`** — the CLI and tooling.
|
||||
- **`@shadcn/react`** — headless React primitives.
|
||||
|
||||
They version on their own lines. A change to one never bumps the other unless a changeset says so.
|
||||
|
||||
## 1. Add a changeset
|
||||
|
||||
Every change that should publish needs a changeset. Run:
|
||||
|
||||
```sh
|
||||
pnpm changeset
|
||||
```
|
||||
|
||||
Select the affected package(s) and bump level. One PR can carry separate changesets for `shadcn` and `@shadcn/react` at different levels. A PR with no changeset publishes nothing.
|
||||
|
||||
## 2. Stable release
|
||||
|
||||
Stable releases are automated by `.github/workflows/release.yml` (the `release` job, on push to `main`):
|
||||
|
||||
1. Merged changesets accumulate on `main`.
|
||||
2. The Changesets action opens/updates a **"Version Packages"** PR that bumps versions and writes changelogs.
|
||||
3. Merging that PR triggers `changeset publish`, which builds all packages (`pnpm build:packages`) and publishes any whose version is ahead of npm — each to the `latest` tag.
|
||||
|
||||
`pnpm build:packages` (`turbo run build --filter=./packages/*`) builds `shadcn` and `@shadcn/react` but never `apps/v4`.
|
||||
|
||||
## 3. Prereleases (per-PR snapshots)
|
||||
|
||||
Add the **`release: beta`** or **`release: rc`** label to a PR. The `prerelease` job in `release.yml`:
|
||||
|
||||
1. Verifies the branch has changesets (a label on the version PR is a no-op, since it consumed them).
|
||||
2. Runs `changeset version --snapshot <channel>` — stamps a unique `0.0.0-<channel>-<timestamp>` on each changeset'd package.
|
||||
3. Builds and runs `changeset publish --tag <channel> --no-git-tag`.
|
||||
4. Uploads the published package list; `prerelease-comment.yml` posts a `pnpm dlx` install line per package and removes the label.
|
||||
|
||||
The label selects the **dist-tag/channel**; the **changesets on the branch** select which packages publish. Snapshots are timestamped, so they never touch `latest` and never collide.
|
||||
|
||||
```sh
|
||||
# Install a snapshot from the PR comment, e.g.:
|
||||
pnpm dlx @shadcn/react@0.0.0-beta-20260624120000
|
||||
```
|
||||
|
||||
## 4. Prerelease trains (sustained `-beta.N` / `-rc.N`)
|
||||
|
||||
For a baking release line (e.g. `1.0.0-rc.0`, `-rc.1`, …) rather than throwaway snapshots, use Changesets pre mode:
|
||||
|
||||
```sh
|
||||
pnpm changeset pre enter rc # writes .changeset/pre.json
|
||||
# ...normal changeset + Version PR cycle now produces -rc.N versions on the rc tag...
|
||||
pnpm changeset pre exit # back to stable; next Version PR ships X.Y.Z on latest
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `pnpm-workspace.yaml` sets `minimumReleaseAge: 2880` (48h), so freshly published stable/beta versions take time to resolve in normal installs. Use `pnpm dlx <pkg>@<exact-snapshot-version>` to test immediately.
|
||||
- Publishing uses npm OIDC/provenance (`id-token: write` + `npm@latest`); no `NPM_TOKEN` secret is needed.
|
||||
1
apps/v4/.gitignore
vendored
1
apps/v4/.gitignore
vendored
@@ -46,3 +46,4 @@ next-env.d.ts
|
||||
.contentlayer
|
||||
.content-collections
|
||||
.source
|
||||
.devtools
|
||||
|
||||
@@ -5,3 +5,4 @@ build
|
||||
.contentlayer
|
||||
registry/__index__.tsx
|
||||
content/docs/components/calendar.mdx
|
||||
registry/styles/**/*.css
|
||||
|
||||
104
apps/v4/app/(app)/(create)/components/accent-picker.tsx
Normal file
104
apps/v4/app/(app)/(create)/components/accent-picker.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { MENU_ACCENTS, type MenuAccentValue } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
export function MenuAccentPicker({
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const currentAccent = MENU_ACCENTS.find(
|
||||
(accent) => accent.value === params.menuAccent
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group/picker relative pr-3 md:pr-0">
|
||||
<Picker>
|
||||
<PickerTrigger>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-xs text-muted-foreground">Menu Accent</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentAccent?.label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="128"
|
||||
height="128"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="size-4 text-foreground"
|
||||
>
|
||||
<path
|
||||
d="M19 12.1294L12.9388 18.207C11.1557 19.9949 10.2641 20.8889 9.16993 20.9877C8.98904 21.0041 8.80705 21.0041 8.62616 20.9877C7.53195 20.8889 6.64039 19.9949 4.85726 18.207L2.83687 16.1811C1.72104 15.0622 1.72104 13.2482 2.83687 12.1294M19 12.1294L10.9184 4.02587M19 12.1294H2.83687M10.9184 4.02587L2.83687 12.1294M10.9184 4.02587L8.89805 2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
data-accent={currentAccent?.value}
|
||||
className="fill-muted-foreground/30 data-[accent=bold]:fill-foreground"
|
||||
></path>
|
||||
<path
|
||||
d="M22 20C22 21.1046 21.1046 22 20 22C18.8954 22 18 21.1046 18 20C18 18.8954 20 17 20 17C20 17 22 18.8954 22 20Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
data-accent={currentAccent?.value}
|
||||
className="fill-muted-foreground/30 data-[accent=bold]:fill-foreground"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</PickerTrigger>
|
||||
<PickerContent
|
||||
anchor={isMobile ? anchorRef : undefined}
|
||||
side={isMobile ? "top" : "right"}
|
||||
align={isMobile ? "center" : "start"}
|
||||
>
|
||||
<PickerRadioGroup
|
||||
value={currentAccent?.value}
|
||||
onValueChange={(value) => {
|
||||
setParams({ menuAccent: value as MenuAccentValue })
|
||||
}}
|
||||
>
|
||||
<PickerGroup>
|
||||
{MENU_ACCENTS.map((accent) => (
|
||||
<PickerRadioItem
|
||||
key={accent.value}
|
||||
value={accent.value}
|
||||
closeOnClick={isMobile}
|
||||
disabled={
|
||||
accent.value === "bold" &&
|
||||
(params.menuColor === "default-translucent" ||
|
||||
params.menuColor === "inverted-translucent")
|
||||
}
|
||||
>
|
||||
{accent.label}
|
||||
</PickerRadioItem>
|
||||
))}
|
||||
</PickerGroup>
|
||||
</PickerRadioGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
<LockButton
|
||||
param="menuAccent"
|
||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
apps/v4/app/(app)/(create)/components/action-menu.tsx
Normal file
88
apps/v4/app/(app)/(create)/components/action-menu.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import Script from "next/script"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/styles/base-nova/ui/command"
|
||||
import { useActionMenu } from "@/app/(app)/(create)/hooks/use-action-menu"
|
||||
|
||||
export const CMD_K_FORWARD_TYPE = "cmd-k-forward"
|
||||
|
||||
export function ActionMenu({
|
||||
itemsByBase,
|
||||
}: {
|
||||
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
|
||||
}) {
|
||||
const {
|
||||
activeRegistryName,
|
||||
getCommandValue,
|
||||
groups,
|
||||
handleSelect,
|
||||
open,
|
||||
setOpen,
|
||||
} = useActionMenu(itemsByBase)
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen} className="animate-none!">
|
||||
<Command loop>
|
||||
<CommandInput placeholder="Search" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No items found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{groups.map((group) =>
|
||||
group.items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={getCommandValue(item)}
|
||||
data-checked={activeRegistryName === item.registryName}
|
||||
className="px-2"
|
||||
onSelect={() => {
|
||||
handleSelect(item.registryName)
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionMenuScript() {
|
||||
return (
|
||||
<Script
|
||||
id="design-system-listener"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
// Forward Cmd/Ctrl + K (and P) to parent
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.key === 'k' || e.key === 'p') && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: '${CMD_K_FORWARD_TYPE}',
|
||||
key: e.key
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
86
apps/v4/app/(app)/(create)/components/base-color-picker.tsx
Normal file
86
apps/v4/app/(app)/(create)/components/base-color-picker.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { useMounted } from "@/hooks/use-mounted"
|
||||
import { BASE_COLORS, type BaseColorName } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
export function BaseColorPicker({
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const mounted = useMounted()
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const currentBaseColor = React.useMemo(
|
||||
() => BASE_COLORS.find((baseColor) => baseColor.name === params.baseColor),
|
||||
[params.baseColor]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
<PickerTrigger>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-xs text-muted-foreground">Base Color</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentBaseColor?.title}
|
||||
</div>
|
||||
</div>
|
||||
{mounted && (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--color":
|
||||
currentBaseColor?.cssVars?.dark?.["muted-foreground"],
|
||||
} 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"}
|
||||
>
|
||||
<PickerRadioGroup
|
||||
value={currentBaseColor?.name}
|
||||
onValueChange={(value) => {
|
||||
setParams({ baseColor: value as BaseColorName })
|
||||
}}
|
||||
>
|
||||
<PickerGroup>
|
||||
{BASE_COLORS.map((baseColor) => (
|
||||
<PickerRadioItem
|
||||
key={baseColor.name}
|
||||
value={baseColor.name}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
{baseColor.title}
|
||||
</PickerRadioItem>
|
||||
))}
|
||||
</PickerGroup>
|
||||
</PickerRadioGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
<LockButton
|
||||
param="baseColor"
|
||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
apps/v4/app/(app)/(create)/components/base-picker.tsx
Normal file
86
apps/v4/app/(app)/(create)/components/base-picker.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { BASES } from "@/registry/config"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
export function BasePicker({
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const currentBase = React.useMemo(
|
||||
() => BASES.find((base) => base.name === params.base),
|
||||
[params.base]
|
||||
)
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(value: string) => {
|
||||
const newBase = BASES.find((base) => base.name === value)
|
||||
if (!newBase) {
|
||||
return
|
||||
}
|
||||
|
||||
setParams({ base: newBase.name })
|
||||
},
|
||||
[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">Base</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentBase?.title}
|
||||
</div>
|
||||
</div>
|
||||
{currentBase?.meta?.logo && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 text-foreground select-none md:right-2.5 *:[svg]:size-4 *:[svg]:text-foreground!"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: currentBase.meta.logo,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PickerTrigger>
|
||||
<PickerContent
|
||||
anchor={isMobile ? anchorRef : undefined}
|
||||
side={isMobile ? "top" : "right"}
|
||||
align={isMobile ? "center" : "start"}
|
||||
>
|
||||
<PickerRadioGroup
|
||||
value={currentBase?.name}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<PickerGroup>
|
||||
{BASES.map((base) => (
|
||||
<PickerRadioItem
|
||||
key={base.name}
|
||||
value={base.name}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
{base.title}
|
||||
</PickerRadioItem>
|
||||
))}
|
||||
</PickerGroup>
|
||||
</PickerRadioGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
apps/v4/app/(app)/(create)/components/chart-color-picker.tsx
Normal file
130
apps/v4/app/(app)/(create)/components/chart-color-picker.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { useMounted } from "@/hooks/use-mounted"
|
||||
import {
|
||||
BASE_COLORS,
|
||||
getThemesForBaseColor,
|
||||
type ChartColorName,
|
||||
} from "@/registry/config"
|
||||
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerSeparator,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
export function ChartColorPicker({
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const mounted = useMounted()
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const availableChartColors = React.useMemo(
|
||||
() => getThemesForBaseColor(params.baseColor),
|
||||
[params.baseColor]
|
||||
)
|
||||
|
||||
const currentChartColor = React.useMemo(
|
||||
() =>
|
||||
availableChartColors.find((theme) => theme.name === params.chartColor),
|
||||
[availableChartColors, params.chartColor]
|
||||
)
|
||||
|
||||
const currentChartColorIsBaseColor = React.useMemo(
|
||||
() => BASE_COLORS.find((baseColor) => baseColor.name === params.chartColor),
|
||||
[params.chartColor]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
<PickerTrigger>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-xs text-muted-foreground">Chart Color</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentChartColor?.title}
|
||||
</div>
|
||||
</div>
|
||||
{mounted && (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--color":
|
||||
currentChartColor?.cssVars?.dark?.[
|
||||
currentChartColorIsBaseColor
|
||||
? "muted-foreground"
|
||||
: "primary"
|
||||
],
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="pointer-events-none absolute top-1/2 right-4 size-4 -translate-y-1/2 rounded-full bg-(--color) select-none md:right-2.5"
|
||||
/>
|
||||
)}
|
||||
</PickerTrigger>
|
||||
<PickerContent
|
||||
anchor={isMobile ? anchorRef : undefined}
|
||||
side={isMobile ? "top" : "right"}
|
||||
align={isMobile ? "center" : "start"}
|
||||
className="max-h-92"
|
||||
>
|
||||
<PickerRadioGroup
|
||||
value={currentChartColor?.name}
|
||||
onValueChange={(value) => {
|
||||
setParams({ chartColor: value as ChartColorName })
|
||||
}}
|
||||
>
|
||||
<PickerGroup>
|
||||
{availableChartColors
|
||||
.filter((theme) =>
|
||||
BASE_COLORS.find((baseColor) => baseColor.name === theme.name)
|
||||
)
|
||||
.map((theme) => (
|
||||
<PickerRadioItem
|
||||
key={theme.name}
|
||||
value={theme.name}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
{theme.title}
|
||||
</PickerRadioItem>
|
||||
))}
|
||||
</PickerGroup>
|
||||
<PickerSeparator />
|
||||
<PickerGroup>
|
||||
{availableChartColors
|
||||
.filter(
|
||||
(theme) =>
|
||||
!BASE_COLORS.find(
|
||||
(baseColor) => baseColor.name === theme.name
|
||||
)
|
||||
)
|
||||
.map((theme) => (
|
||||
<PickerRadioItem
|
||||
key={theme.name}
|
||||
value={theme.name}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
{theme.title}
|
||||
</PickerRadioItem>
|
||||
))}
|
||||
</PickerGroup>
|
||||
</PickerRadioGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
<LockButton
|
||||
param="chartColor"
|
||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
apps/v4/app/(app)/(create)/components/copy-preset.tsx
Normal file
45
apps/v4/app/(app)/(create)/components/copy-preset.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { usePresetCode } from "@/app/(app)/(create)/hooks/use-design-system"
|
||||
|
||||
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
|
||||
const presetCode = usePresetCode()
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
const label = hasCopied ? "Copied" : `--preset ${presetCode}`
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasCopied) {
|
||||
const timer = setTimeout(() => setHasCopied(false), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [hasCopied])
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
copyToClipboardWithMeta(`--preset ${presetCode}`, {
|
||||
name: "copy_preset_command",
|
||||
properties: {
|
||||
preset: presetCode,
|
||||
},
|
||||
})
|
||||
setHasCopied(true)
|
||||
}, [presetCode])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
title={label}
|
||||
className={cn(
|
||||
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="block min-w-0 truncate">{label}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
72
apps/v4/app/(app)/(create)/components/create-devtools.tsx
Normal file
72
apps/v4/app/(app)/(create)/components/create-devtools.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { BASES } from "@/registry/config"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import { getDocsPathForItem } from "@/app/(app)/(create)/lib/devtools"
|
||||
import {
|
||||
serializeDesignSystemSearchParams,
|
||||
useDesignSystemSearchParams,
|
||||
} from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
export function CreateDevtools() {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const previewUrl = React.useMemo(
|
||||
() =>
|
||||
serializeDesignSystemSearchParams(
|
||||
`/preview/${params.base}/${params.item}`,
|
||||
params
|
||||
),
|
||||
[params]
|
||||
)
|
||||
|
||||
const docsUrl = React.useMemo(
|
||||
() => getDocsPathForItem(params.base, params.item),
|
||||
[params.base, params.item]
|
||||
)
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dark absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 items-center gap-0.5 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
|
||||
{BASES.map((base) => (
|
||||
<Button
|
||||
key={base.name}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-active={params.base === base.name}
|
||||
className="h-7 min-w-8 cursor-pointer rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
|
||||
onClick={() => setParams({ base: base.name })}
|
||||
>
|
||||
{base.name === "radix" ? "Radix" : "Base"}
|
||||
</Button>
|
||||
))}
|
||||
<div className="mx-0.5 h-4 w-px bg-border/80" />
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<a href={previewUrl} target="_blank" rel="noreferrer">
|
||||
Open in New Tab
|
||||
</a>
|
||||
</Button>
|
||||
<div className="mx-0.5 h-4 w-px bg-border/80" />
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<a href={docsUrl} target="_blank" rel="noreferrer">
|
||||
Open Docs
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
apps/v4/app/(app)/(create)/components/create-skeleton.tsx
Normal file
15
apps/v4/app/(app)/(create)/components/create-skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
||||
|
||||
export function CreateSkeleton() {
|
||||
return (
|
||||
<div className="relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden section-soft [--customizer-width:--spacing(48)] [--gap:--spacing(4)] md:[--gap:--spacing(6)] 2xl:[--customizer-width:--spacing(56)]">
|
||||
<div
|
||||
data-slot="designer"
|
||||
className="flex min-h-0 flex-1 flex-col gap-(--gap) p-(--gap) pt-[calc(var(--gap)*0.25)] md:flex-row-reverse"
|
||||
>
|
||||
<Skeleton className="flex-1 rounded-2xl" />
|
||||
<Skeleton className="min-h-[151px] w-full self-start rounded-2xl md:h-full md:max-h-full md:min-h-0 md:w-(--customizer-width)" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
apps/v4/app/(app)/(create)/components/customizer.tsx
Normal file
131
apps/v4/app/(app)/(create)/components/customizer.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { getThemesForBaseColor, STYLES } from "@/registry/config"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/styles/base-nova/ui/card"
|
||||
import { FieldGroup, FieldSeparator } from "@/styles/base-nova/ui/field"
|
||||
import { MenuAccentPicker } from "@/app/(app)/(create)/components/accent-picker"
|
||||
import { ActionMenu } from "@/app/(app)/(create)/components/action-menu"
|
||||
import { BaseColorPicker } from "@/app/(app)/(create)/components/base-color-picker"
|
||||
import { BasePicker } from "@/app/(app)/(create)/components/base-picker"
|
||||
import { ChartColorPicker } from "@/app/(app)/(create)/components/chart-color-picker"
|
||||
import { CopyPreset } from "@/app/(app)/(create)/components/copy-preset"
|
||||
import { FontPicker } from "@/app/(app)/(create)/components/font-picker"
|
||||
import { IconLibraryPicker } from "@/app/(app)/(create)/components/icon-library-picker"
|
||||
import { MainMenu } from "@/app/(app)/(create)/components/main-menu"
|
||||
import { MenuColorPicker } from "@/app/(app)/(create)/components/menu-picker"
|
||||
import { OpenPreset } from "@/app/(app)/(create)/components/open-preset"
|
||||
import { RadiusPicker } from "@/app/(app)/(create)/components/radius-picker"
|
||||
import { RandomButton } from "@/app/(app)/(create)/components/random-button"
|
||||
import { ResetDialog } from "@/app/(app)/(create)/components/reset-button"
|
||||
import { StylePicker } from "@/app/(app)/(create)/components/style-picker"
|
||||
import { ThemePicker } from "@/app/(app)/(create)/components/theme-picker"
|
||||
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/(create)/lib/fonts"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
// Only visible when user clicks "Create Project". Rendered client-only to
|
||||
// avoid a useId hydration mismatch on the Base UI dialog trigger. The loading
|
||||
// placeholder mirrors the trigger button exactly so there is no layout shift.
|
||||
const ProjectForm = dynamic(
|
||||
() =>
|
||||
import("@/app/(app)/(create)/components/project-form").then(
|
||||
(m) => m.ProjectForm
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Button disabled aria-hidden>
|
||||
Get Code
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
export function Customizer({
|
||||
itemsByBase,
|
||||
}: {
|
||||
itemsByBase: Record<string, Pick<RegistryItem, "name" | "title" | "type">[]>
|
||||
}) {
|
||||
const [params] = useDesignSystemSearchParams()
|
||||
const isMobile = useIsMobile()
|
||||
const anchorRef = React.useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const availableThemes = React.useMemo(
|
||||
() => getThemesForBaseColor(params.baseColor),
|
||||
[params.baseColor]
|
||||
)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="dark top-24 right-12 isolate z-10 max-h-full min-h-0 w-full self-start rounded-2xl bg-card/90 backdrop-blur-xl md:w-(--customizer-width)"
|
||||
ref={anchorRef}
|
||||
size="sm"
|
||||
>
|
||||
<CardHeader className="hidden items-center justify-between gap-2 border-b group-data-reversed/layout:flex-row-reverse md:flex">
|
||||
<MainMenu />
|
||||
</CardHeader>
|
||||
<CardContent className="no-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden md:overflow-y-auto">
|
||||
<FieldGroup className="flex-row gap-2.5 py-px **:data-[slot=field-separator]:-mx-4 **:data-[slot=field-separator]:w-auto md:flex-col md:gap-3.25">
|
||||
<StylePicker
|
||||
styles={STYLES}
|
||||
isMobile={isMobile}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
<FieldSeparator className="hidden md:block" />
|
||||
<BaseColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<ThemePicker
|
||||
themes={availableThemes}
|
||||
isMobile={isMobile}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
<ChartColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<FieldSeparator className="hidden md:block" />
|
||||
<FontPicker
|
||||
label="Heading"
|
||||
param="fontHeading"
|
||||
fonts={FONT_HEADING_OPTIONS}
|
||||
isMobile={isMobile}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
<FontPicker
|
||||
label="Font"
|
||||
param="font"
|
||||
fonts={FONTS}
|
||||
isMobile={isMobile}
|
||||
anchorRef={anchorRef}
|
||||
/>
|
||||
<FieldSeparator className="hidden md:block" />
|
||||
<IconLibraryPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<RadiusPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<FieldSeparator className="hidden md:block" />
|
||||
<MenuColorPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
<MenuAccentPicker isMobile={isMobile} anchorRef={anchorRef} />
|
||||
{isMobile && <BasePicker isMobile={isMobile} anchorRef={anchorRef} />}
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
|
||||
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
|
||||
<OpenPreset
|
||||
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
|
||||
label={isMobile ? "Open" : "Open Preset"}
|
||||
/>
|
||||
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
|
||||
<ActionMenu itemsByBase={itemsByBase} />
|
||||
<ResetDialog />
|
||||
</CardFooter>
|
||||
<CardFooter className="-mt-3 hidden min-w-0 gap-2 md:flex md:flex-col md:**:[button,a]:w-full">
|
||||
<ProjectForm />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
323
apps/v4/app/(app)/(create)/components/design-system-provider.tsx
Normal file
323
apps/v4/app/(app)/(create)/components/design-system-provider.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import {
|
||||
buildRegistryTheme,
|
||||
DEFAULT_CONFIG,
|
||||
POINTER_CURSOR_SELECTOR,
|
||||
type DesignSystemConfig,
|
||||
} from "@/registry/config"
|
||||
import { useIframeMessageListener } from "@/app/(app)/(create)/hooks/use-iframe-sync"
|
||||
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
|
||||
import {
|
||||
useDesignSystemSearchParams,
|
||||
type DesignSystemSearchParams,
|
||||
} from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
const THEME_STYLE_ELEMENT_ID = "design-system-theme-vars"
|
||||
const MANAGED_BODY_CLASS_PREFIXES = ["style-", "base-color-"] as const
|
||||
const POINTER_CURSOR_CSS = `@layer base {
|
||||
${POINTER_CURSOR_SELECTOR} {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type RegistryThemeCssVars = NonNullable<
|
||||
ReturnType<typeof buildRegistryTheme>["cssVars"]
|
||||
>
|
||||
|
||||
function removeManagedBodyClasses(body: Element) {
|
||||
for (const className of Array.from(body.classList)) {
|
||||
if (
|
||||
MANAGED_BODY_CLASS_PREFIXES.some((prefix) => className.startsWith(prefix))
|
||||
) {
|
||||
body.classList.remove(className)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildCssRule(selector: string, cssVars?: Record<string, string>) {
|
||||
const declarations = Object.entries(cssVars ?? {})
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([key, value]) => ` --${key}: ${value};`)
|
||||
.join("\n")
|
||||
|
||||
if (!declarations) {
|
||||
return `${selector} {}\n`
|
||||
}
|
||||
|
||||
return `${selector} {\n${declarations}\n}\n`
|
||||
}
|
||||
|
||||
function buildThemeCssText(cssVars: RegistryThemeCssVars, pointer: boolean) {
|
||||
return [
|
||||
buildCssRule(":root", {
|
||||
...(cssVars.theme ?? {}),
|
||||
...(cssVars.light ?? {}),
|
||||
}),
|
||||
buildCssRule(".dark", cssVars.dark),
|
||||
pointer ? POINTER_CURSOR_CSS : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function DesignSystemProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [searchParams, setSearchParams] = useDesignSystemSearchParams({
|
||||
shallow: true, // No need to go through the server…
|
||||
history: "replace", // …or push updates into the iframe history.
|
||||
})
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {
|
||||
style,
|
||||
theme,
|
||||
font,
|
||||
fontHeading,
|
||||
baseColor,
|
||||
chartColor,
|
||||
menuAccent,
|
||||
menuColor,
|
||||
pointer,
|
||||
radius,
|
||||
} = searchParams
|
||||
const effectiveRadius = style === "lyra" ? "none" : radius
|
||||
const selectedFont = React.useMemo(
|
||||
() => FONTS.find((fontOption) => fontOption.value === font),
|
||||
[font]
|
||||
)
|
||||
const selectedHeadingFont = React.useMemo(() => {
|
||||
if (fontHeading === "inherit" || fontHeading === font) {
|
||||
return selectedFont
|
||||
}
|
||||
|
||||
return FONTS.find((fontOption) => fontOption.value === fontHeading)
|
||||
}, [font, fontHeading, selectedFont])
|
||||
const initialFontSansRef = React.useRef<string | null>(null)
|
||||
const initialFontHeadingRef = React.useRef<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
initialFontSansRef.current =
|
||||
document.documentElement.style.getPropertyValue("--font-sans")
|
||||
initialFontHeadingRef.current =
|
||||
document.documentElement.style.getPropertyValue("--font-heading")
|
||||
|
||||
return () => {
|
||||
removeManagedBodyClasses(document.body)
|
||||
document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove()
|
||||
|
||||
if (initialFontSansRef.current) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--font-sans",
|
||||
initialFontSansRef.current
|
||||
)
|
||||
} else {
|
||||
document.documentElement.style.removeProperty("--font-sans")
|
||||
}
|
||||
|
||||
if (initialFontHeadingRef.current) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--font-heading",
|
||||
initialFontHeadingRef.current
|
||||
)
|
||||
} else {
|
||||
document.documentElement.style.removeProperty("--font-heading")
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDesignSystemMessage = React.useCallback(
|
||||
(nextParams: DesignSystemSearchParams) => {
|
||||
setSearchParams(nextParams)
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (style === "lyra" || (style === "sera" && radius !== "none")) {
|
||||
setSearchParams({ radius: "none" })
|
||||
return
|
||||
}
|
||||
|
||||
if (style === "rhea" && radius === "large") {
|
||||
setSearchParams({ radius: "default" })
|
||||
}
|
||||
}, [style, radius, setSearchParams])
|
||||
|
||||
// Use useLayoutEffect for synchronous style updates to prevent flash.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!style || !theme || !font || !baseColor) {
|
||||
return
|
||||
}
|
||||
|
||||
const body = document.body
|
||||
|
||||
// Iterate over a snapshot so removals do not affect traversal.
|
||||
removeManagedBodyClasses(body)
|
||||
body.classList.add(`style-${style}`, `base-color-${baseColor}`)
|
||||
|
||||
// Update font.
|
||||
// Always set --font-sans for the preview so the selected font is visible.
|
||||
// The font type (sans/serif/mono) is metadata for the CLI updater.
|
||||
if (selectedFont) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--font-sans",
|
||||
selectedFont.font.style.fontFamily
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedHeadingFont) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--font-heading",
|
||||
selectedHeadingFont.font.style.fontFamily
|
||||
)
|
||||
}
|
||||
|
||||
setIsReady(true)
|
||||
}, [
|
||||
style,
|
||||
theme,
|
||||
font,
|
||||
fontHeading,
|
||||
baseColor,
|
||||
selectedFont,
|
||||
selectedHeadingFont,
|
||||
])
|
||||
|
||||
const registryTheme = React.useMemo(() => {
|
||||
if (!baseColor || !theme || !menuAccent || !effectiveRadius) {
|
||||
return null
|
||||
}
|
||||
|
||||
const config: DesignSystemConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
baseColor,
|
||||
theme,
|
||||
chartColor,
|
||||
menuAccent,
|
||||
radius: effectiveRadius,
|
||||
}
|
||||
|
||||
return buildRegistryTheme(config)
|
||||
}, [baseColor, theme, chartColor, menuAccent, effectiveRadius])
|
||||
|
||||
// Use useLayoutEffect for synchronous CSS var updates.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!registryTheme || !registryTheme.cssVars) {
|
||||
return
|
||||
}
|
||||
|
||||
let styleElement = document.getElementById(
|
||||
THEME_STYLE_ELEMENT_ID
|
||||
) as HTMLStyleElement | null
|
||||
|
||||
if (!styleElement) {
|
||||
styleElement = document.createElement("style")
|
||||
styleElement.id = THEME_STYLE_ELEMENT_ID
|
||||
document.head.appendChild(styleElement)
|
||||
}
|
||||
|
||||
styleElement.textContent = buildThemeCssText(registryTheme.cssVars, pointer)
|
||||
}, [registryTheme, pointer])
|
||||
|
||||
// Handle menu color inversion by adding/removing dark class to elements with cn-menu-target.
|
||||
// useLayoutEffect to apply classes synchronously before paint, avoiding flash.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!menuColor) {
|
||||
return
|
||||
}
|
||||
|
||||
const isInvertedMenu =
|
||||
menuColor === "inverted" || menuColor === "inverted-translucent"
|
||||
const isTranslucentMenu =
|
||||
menuColor === "default-translucent" ||
|
||||
menuColor === "inverted-translucent"
|
||||
let frameId = 0
|
||||
|
||||
const updateMenuElements = () => {
|
||||
const allElements = document.querySelectorAll<HTMLElement>(
|
||||
".cn-menu-target, [data-menu-translucent]"
|
||||
)
|
||||
|
||||
if (allElements.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Disable transitions while toggling classes.
|
||||
allElements.forEach((element) => {
|
||||
element.style.transition = "none"
|
||||
})
|
||||
|
||||
allElements.forEach((element) => {
|
||||
if (element.classList.contains("cn-menu-target")) {
|
||||
if (isInvertedMenu) {
|
||||
element.classList.add("dark")
|
||||
} else {
|
||||
element.classList.remove("dark")
|
||||
}
|
||||
}
|
||||
|
||||
// When translucent is enabled, move from data-attr to class so styles apply.
|
||||
// When disabled, move back to a data-attr so the element stays queryable
|
||||
// for future toggles without losing its identity as a menu element.
|
||||
if (isTranslucentMenu) {
|
||||
element.classList.add("cn-menu-translucent")
|
||||
element.removeAttribute("data-menu-translucent")
|
||||
} else if (element.classList.contains("cn-menu-translucent")) {
|
||||
element.classList.remove("cn-menu-translucent")
|
||||
element.setAttribute("data-menu-translucent", "")
|
||||
}
|
||||
})
|
||||
|
||||
// Force a reflow, then re-enable transitions.
|
||||
void document.body.offsetHeight
|
||||
allElements.forEach((element) => {
|
||||
element.style.transition = ""
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleMenuUpdate = () => {
|
||||
if (frameId) {
|
||||
return
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = 0
|
||||
updateMenuElements()
|
||||
})
|
||||
}
|
||||
|
||||
// Update existing menu elements.
|
||||
updateMenuElements()
|
||||
|
||||
// Watch for new menu elements being added to the DOM.
|
||||
const observer = new MutationObserver(() => {
|
||||
scheduleMenuUpdate()
|
||||
})
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (frameId) {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
}
|
||||
}, [menuColor])
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
78
apps/v4/app/(app)/(create)/components/history-buttons.tsx
Normal file
78
apps/v4/app/(app)/(create)/components/history-buttons.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import Script from "next/script"
|
||||
import { Redo02Icon, Undo02Icon } from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { useHistory } from "@/app/(app)/(create)/hooks/use-history"
|
||||
|
||||
export const UNDO_FORWARD_TYPE = "undo-forward"
|
||||
export const REDO_FORWARD_TYPE = "redo-forward"
|
||||
|
||||
export function HistoryButtons() {
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Undo"
|
||||
disabled={!canGoBack}
|
||||
onClick={goBack}
|
||||
>
|
||||
<HugeiconsIcon icon={Undo02Icon} />
|
||||
<span className="sr-only">Undo</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Redo"
|
||||
disabled={!canGoForward}
|
||||
onClick={goForward}
|
||||
>
|
||||
<HugeiconsIcon icon={Redo02Icon} />
|
||||
<span className="sr-only">Redo</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HistoryScript() {
|
||||
return (
|
||||
<Script
|
||||
id="history-listener"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (!e.metaKey && !e.ctrlKey) return;
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
var key = e.key.toLowerCase();
|
||||
if ((key === 'z' && e.shiftKey) || (key === 'y' && e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({ type: '${REDO_FORWARD_TYPE}' }, '*');
|
||||
}
|
||||
} else if (key === 'z') {
|
||||
e.preventDefault();
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({ type: '${UNDO_FORWARD_TYPE}' }, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
171
apps/v4/app/(app)/(create)/components/icon-library-picker.tsx
Normal file
171
apps/v4/app/(app)/(create)/components/icon-library-picker.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { iconLibraries, type IconLibraryName } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
const logos = {
|
||||
lucide: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
d="M14 12a4 4 0 0 0-8 0 8 8 0 1 0 16 0 11.97 11.97 0 0 0-4-8.944"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
d="M10 12a4 4 0 0 0 8 0 8 8 0 1 0-16 0 11.97 11.97 0 0 0 4.063 9"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
tabler: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
viewBox="0 0 32 32"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M31.288 7.107A8.83 8.83 0 0 0 24.893.712a55.9 55.9 0 0 0-17.786 0A8.83 8.83 0 0 0 .712 7.107a55.9 55.9 0 0 0 0 17.786 8.83 8.83 0 0 0 6.395 6.395c5.895.95 11.89.95 17.786 0a8.83 8.83 0 0 0 6.395-6.395c.95-5.895.95-11.89 0-17.786"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="m17.884 9.076 1.5-2.488 6.97 6.977-2.492 1.494zm-7.96 3.127 7.814-.909 3.91 3.66-.974 7.287-9.582 2.159a3.06 3.06 0 0 1-2.17-.329l5.244-4.897c.91.407 2.003.142 2.587-.626.584-.77.488-1.818-.226-2.484s-1.84-.755-2.664-.21c-.823.543-1.107 1.562-.67 2.412l-5.245 4.89a2.53 2.53 0 0 1-.339-2.017z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
hugeicons: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="128"
|
||||
height="128"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M2 9.5H22" stroke="currentColor"></path>
|
||||
<path
|
||||
d="M20.5 9.5H3.5L4.23353 15.3682C4.59849 18.2879 4.78097 19.7477 5.77343 20.6239C6.76589 21.5 8.23708 21.5 11.1795 21.5H12.8205C15.7629 21.5 17.2341 21.5 18.2266 20.6239C19.219 19.7477 19.4015 18.2879 19.7665 15.3682L20.5 9.5Z"
|
||||
stroke="currentColor"
|
||||
></path>
|
||||
<path
|
||||
d="M5 9C5 5.41015 8.13401 2.5 12 2.5C15.866 2.5 19 5.41015 19 9"
|
||||
stroke="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
phosphor: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 32 32"
|
||||
width="32"
|
||||
height="32"
|
||||
>
|
||||
<path fill="none" d="M0 0h32v32H0z" />
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 5h9v16H9zm9 16v9a9 9 0 0 1-9-9M9 5l9 16m0 0h1a8 8 0 0 0 0-16h-1"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
remixicon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 2C17.5228 2 22 6.47715 22 12C22 15.3137 19.3137 18 16 18C12.6863 18 10 15.3137 10 12C10 11.4477 9.55228 11 9 11C8.44772 11 8 11.4477 8 12C8 16.4183 11.5817 20 16 20C16.8708 20 17.7084 19.8588 18.4932 19.6016C16.7458 21.0956 14.4792 22 12 22C6.6689 22 2.3127 17.8283 2.0166 12.5713C2.23647 9.45772 4.83048 7 8 7C11.3137 7 14 9.68629 14 13C14 13.5523 14.4477 14 15 14C15.5523 14 16 13.5523 16 13C16 8.58172 12.4183 5 8 5C6.50513 5 5.1062 5.41032 3.90918 6.12402C5.72712 3.62515 8.67334 2 12 2Z" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
export function IconLibraryPicker({
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const currentIconLibrary = React.useMemo(
|
||||
() => iconLibraries[params.iconLibrary as keyof typeof iconLibraries],
|
||||
[params.iconLibrary]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
<PickerTrigger>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-xs text-muted-foreground">Icon Library</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentIconLibrary?.title}
|
||||
</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 *:[svg]:text-foreground!">
|
||||
{logos[currentIconLibrary?.name as keyof typeof logos]}
|
||||
</div>
|
||||
</PickerTrigger>
|
||||
<PickerContent
|
||||
anchor={isMobile ? anchorRef : undefined}
|
||||
side={isMobile ? "top" : "right"}
|
||||
align={isMobile ? "center" : "start"}
|
||||
>
|
||||
<PickerRadioGroup
|
||||
value={currentIconLibrary?.name}
|
||||
onValueChange={(value) => {
|
||||
setParams({ iconLibrary: value as IconLibraryName })
|
||||
}}
|
||||
>
|
||||
<PickerGroup>
|
||||
{Object.values(iconLibraries).map((iconLibrary) => (
|
||||
<PickerRadioItem
|
||||
key={iconLibrary.name}
|
||||
value={iconLibrary.name}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
{iconLibrary.title}
|
||||
</PickerRadioItem>
|
||||
))}
|
||||
</PickerGroup>
|
||||
</PickerRadioGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
<LockButton
|
||||
param="iconLibrary"
|
||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { lazy, Suspense } from "react"
|
||||
import { SquareIcon } from "lucide-react"
|
||||
import type { IconLibraryName } from "shadcn/icons"
|
||||
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
const IconLucide = lazy(() =>
|
||||
import("@/registry/icons/icon-lucide").then((mod) => ({
|
||||
@@ -36,6 +36,15 @@ const IconRemixicon = lazy(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
// 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
|
||||
}: {
|
||||
108
apps/v4/app/(app)/(create)/components/item-explorer.tsx
Normal file
108
apps/v4/app/(app)/(create)/components/item-explorer.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type Base } from "@/registry/bases"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/styles/base-nova/ui/collapsible"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/styles/base-nova/ui/sidebar"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
import { groupItemsByType } from "@/app/(app)/(create)/lib/utils"
|
||||
|
||||
const cachedGroupedItems = React.cache(
|
||||
(items: Pick<RegistryItem, "name" | "title" | "type">[]) => {
|
||||
return groupItemsByType(items)
|
||||
}
|
||||
)
|
||||
|
||||
export function ItemExplorer({
|
||||
base,
|
||||
items,
|
||||
}: {
|
||||
base: Base["name"]
|
||||
items: Pick<RegistryItem, "name" | "title" | "type">[]
|
||||
}) {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const groupedItems = React.useMemo(() => cachedGroupedItems(items), [items])
|
||||
|
||||
const currentItem = React.useMemo(
|
||||
() => items.find((item) => item.name === params.item) ?? null,
|
||||
[items, params.item]
|
||||
)
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
className="sticky z-30 hidden h-full overscroll-none bg-transparent xl:flex"
|
||||
collapsible="none"
|
||||
>
|
||||
<SidebarContent className="-mx-1 no-scrollbar overflow-x-hidden">
|
||||
{groupedItems.map((group) => (
|
||||
<Collapsible
|
||||
key={group.type}
|
||||
defaultOpen
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarGroup className="px-1 py-0">
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1 py-1.5 text-[0.8rem] font-medium [&[data-state=open]>svg]:rotate-90">
|
||||
<ChevronRightIcon className="size-3.5 text-muted-foreground transition-transform" />
|
||||
<span>{group.title}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="relative ml-1.5 border-l border-border/50 pl-2">
|
||||
{group.items.map((item, index) => (
|
||||
<SidebarMenuItem key={item.name} className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 -left-2 h-px w-2 border-t border-border/50",
|
||||
index === group.items.length - 1 && "bg-sidebar"
|
||||
)}
|
||||
/>
|
||||
{index === group.items.length - 1 && (
|
||||
<div className="absolute top-1/2 -bottom-1 -left-2.5 w-1 bg-sidebar" />
|
||||
)}
|
||||
<SidebarMenuButton
|
||||
onClick={() => setParams({ item: item.name })}
|
||||
className="relative h-[26px] w-fit cursor-pointer overflow-visible border border-transparent text-[0.8rem] font-normal after:absolute after:inset-x-0 after:-inset-y-1 after:z-0 after:rounded-md data-[active=true]:border-accent data-[active=true]:bg-accent 3xl:fixed:w-full 3xl:fixed:max-w-48"
|
||||
data-active={item.name === currentItem?.name}
|
||||
isActive={item.name === currentItem?.name}
|
||||
>
|
||||
{item.title}
|
||||
<span className="absolute inset-0 flex w-(--sidebar-width) bg-transparent" />
|
||||
</SidebarMenuButton>
|
||||
<Link
|
||||
href={`/preview/${base}/${item.name}`}
|
||||
prefetch
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
44
apps/v4/app/(app)/(create)/components/lock-button.tsx
Normal file
44
apps/v4/app/(app)/(create)/components/lock-button.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
SquareLock01Icon,
|
||||
SquareUnlock01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
useLocks,
|
||||
type LockableParam,
|
||||
} from "@/app/(app)/(create)/hooks/use-locks"
|
||||
|
||||
export function LockButton({
|
||||
param,
|
||||
className,
|
||||
}: {
|
||||
param: LockableParam
|
||||
className?: string
|
||||
}) {
|
||||
const { isLocked, toggleLock } = useLocks()
|
||||
const locked = isLocked(param)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={locked ? "Unlock" : "Lock"}
|
||||
aria-label={locked ? "Unlock" : "Lock"}
|
||||
onClick={() => toggleLock(param)}
|
||||
data-locked={locked}
|
||||
className={cn(
|
||||
"flex size-4 cursor-pointer items-center justify-center rounded opacity-0 ring-foreground/60 transition-opacity outline-none group-focus-within/picker:opacity-100 group-hover/picker:opacity-100 focus:opacity-100 focus-visible:ring-1 data-[locked=true]:opacity-100 pointer-coarse:hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={locked ? SquareLock01Icon : SquareUnlock01Icon}
|
||||
strokeWidth={2}
|
||||
className="size-5 text-foreground"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
88
apps/v4/app/(app)/(create)/components/main-menu.tsx
Normal file
88
apps/v4/app/(app)/(create)/components/main-menu.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu09Icon } from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerItem,
|
||||
PickerSeparator,
|
||||
PickerShortcut,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/(create)/components/picker"
|
||||
import { useActionMenuTrigger } from "@/app/(app)/(create)/hooks/use-action-menu"
|
||||
import { useHistory } from "@/app/(app)/(create)/hooks/use-history"
|
||||
import { useOpenPresetTrigger } from "@/app/(app)/(create)/hooks/use-open-preset"
|
||||
import { useRandom } from "@/app/(app)/(create)/hooks/use-random"
|
||||
import { useReset } from "@/app/(app)/(create)/hooks/use-reset"
|
||||
import { useThemeToggle } from "@/app/(app)/(create)/hooks/use-theme-toggle"
|
||||
|
||||
const APPLE_PLATFORM_REGEX = /Mac|iPhone|iPad|iPod/
|
||||
|
||||
export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
|
||||
const [isMac, setIsMac] = React.useState(false)
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
|
||||
const { openActionMenu } = useActionMenuTrigger()
|
||||
const { openPreset } = useOpenPresetTrigger()
|
||||
const { randomize } = useRandom()
|
||||
const { toggleTheme } = useThemeToggle()
|
||||
const { setShowResetDialog } = useReset()
|
||||
|
||||
React.useEffect(() => {
|
||||
const platform = navigator.platform
|
||||
const userAgent = navigator.userAgent
|
||||
setIsMac(APPLE_PLATFORM_REGEX.test(platform || userAgent))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Picker>
|
||||
<PickerTrigger
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-lg px-1.75 ring-1 ring-foreground/10 focus-visible:ring-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">Menu</span>
|
||||
<HugeiconsIcon icon={Menu09Icon} strokeWidth={2} className="size-5" />
|
||||
</PickerTrigger>
|
||||
<PickerContent side="right" align="start" alignOffset={-8}>
|
||||
<PickerGroup>
|
||||
<PickerItem onClick={openActionMenu}>
|
||||
Navigate...
|
||||
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerItem onClick={openPreset}>
|
||||
Open Preset... <PickerShortcut>O</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerItem onClick={randomize}>
|
||||
Shuffle <PickerShortcut>R</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerItem onClick={toggleTheme}>
|
||||
Light/Dark <PickerShortcut>D</PickerShortcut>
|
||||
</PickerItem>
|
||||
</PickerGroup>
|
||||
<PickerSeparator />
|
||||
<PickerGroup>
|
||||
<PickerItem onClick={goBack} disabled={!canGoBack}>
|
||||
Undo <PickerShortcut>{isMac ? "⌘Z" : "Ctrl+Z"}</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerItem onClick={goForward} disabled={!canGoForward}>
|
||||
Redo{" "}
|
||||
<PickerShortcut>{isMac ? "⇧⌘Z" : "Ctrl+Shift+Z"}</PickerShortcut>
|
||||
</PickerItem>
|
||||
<PickerSeparator />
|
||||
<PickerItem onClick={() => setShowResetDialog(true)}>
|
||||
Reset <PickerShortcut>⇧R</PickerShortcut>
|
||||
</PickerItem>
|
||||
</PickerGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
169
apps/v4/app/(app)/(create)/components/menu-picker.tsx
Normal file
169
apps/v4/app/(app)/(create)/components/menu-picker.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu02Icon } from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { useMounted } from "@/hooks/use-mounted"
|
||||
import { type MenuColorValue } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerLabel,
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerSeparator,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/(create)/components/picker"
|
||||
import {
|
||||
isTranslucentMenuColor,
|
||||
useDesignSystemSearchParams,
|
||||
} from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
type ColorChoice = "default" | "inverted"
|
||||
type SurfaceChoice = "solid" | "translucent"
|
||||
|
||||
function getMenuColorValue(
|
||||
color: ColorChoice,
|
||||
translucent: boolean
|
||||
): MenuColorValue {
|
||||
if (color === "default") {
|
||||
return translucent ? "default-translucent" : "default"
|
||||
}
|
||||
|
||||
return translucent ? "inverted-translucent" : "inverted"
|
||||
}
|
||||
|
||||
const MENU_OPTIONS: { value: MenuColorValue; label: string }[] = [
|
||||
{ value: "default", label: "Default / Solid" },
|
||||
{ value: "default-translucent", label: "Default / Translucent" },
|
||||
{ value: "inverted", label: "Inverted / Solid" },
|
||||
{ value: "inverted-translucent", label: "Inverted / Translucent" },
|
||||
]
|
||||
|
||||
export function MenuColorPicker({
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const mounted = useMounted()
|
||||
const lastSolidMenuAccentRef = React.useRef(params.menuAccent)
|
||||
const isDark = mounted && resolvedTheme === "dark"
|
||||
const currentMenu = MENU_OPTIONS.find(
|
||||
(menu) => menu.value === params.menuColor
|
||||
)
|
||||
const colorChoice: ColorChoice =
|
||||
params.menuColor === "inverted" ||
|
||||
params.menuColor === "inverted-translucent"
|
||||
? "inverted"
|
||||
: "default"
|
||||
const surfaceChoice: SurfaceChoice =
|
||||
params.menuColor === "default-translucent" ||
|
||||
params.menuColor === "inverted-translucent"
|
||||
? "translucent"
|
||||
: "solid"
|
||||
|
||||
React.useEffect(() => {
|
||||
if (surfaceChoice === "solid") {
|
||||
lastSolidMenuAccentRef.current = params.menuAccent
|
||||
}
|
||||
}, [params.menuAccent, surfaceChoice])
|
||||
|
||||
const setColor = (color: ColorChoice) => {
|
||||
const nextMenuColor = getMenuColorValue(
|
||||
color,
|
||||
surfaceChoice === "translucent"
|
||||
)
|
||||
|
||||
setParams({
|
||||
menuColor: nextMenuColor,
|
||||
...(isTranslucentMenuColor(nextMenuColor) && { menuAccent: "subtle" }),
|
||||
})
|
||||
}
|
||||
|
||||
const setSurface = (choice: SurfaceChoice) => {
|
||||
const isTranslucent = choice === "translucent"
|
||||
const nextMenuColor = getMenuColorValue(colorChoice, isTranslucent)
|
||||
|
||||
setParams({
|
||||
menuColor: nextMenuColor,
|
||||
menuAccent: isTranslucent ? "subtle" : lastSolidMenuAccentRef.current,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
<PickerTrigger>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-xs text-muted-foreground">Menu</div>
|
||||
<div className="line-clamp-1 max-w-[80%] truncate text-sm font-medium text-foreground">
|
||||
{currentMenu?.label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center text-base text-foreground select-none md:right-2.5">
|
||||
<HugeiconsIcon
|
||||
icon={Menu02Icon}
|
||||
strokeWidth={2}
|
||||
className="size-4"
|
||||
/>
|
||||
</div>
|
||||
</PickerTrigger>
|
||||
<PickerContent
|
||||
anchor={isMobile ? anchorRef : undefined}
|
||||
side={isMobile ? "top" : "right"}
|
||||
align={isMobile ? "center" : "start"}
|
||||
>
|
||||
<PickerGroup>
|
||||
<PickerLabel>Color</PickerLabel>
|
||||
<PickerRadioGroup
|
||||
value={colorChoice}
|
||||
onValueChange={(value) => {
|
||||
setColor(value as ColorChoice)
|
||||
}}
|
||||
>
|
||||
<PickerRadioItem value="default" closeOnClick={isMobile}>
|
||||
Default
|
||||
</PickerRadioItem>
|
||||
<PickerRadioItem
|
||||
value="inverted"
|
||||
closeOnClick={isMobile}
|
||||
disabled={isDark}
|
||||
>
|
||||
Inverted
|
||||
</PickerRadioItem>
|
||||
</PickerRadioGroup>
|
||||
</PickerGroup>
|
||||
<PickerSeparator />
|
||||
<PickerGroup>
|
||||
<PickerLabel>Appearance</PickerLabel>
|
||||
<PickerRadioGroup
|
||||
value={surfaceChoice}
|
||||
onValueChange={(value) => {
|
||||
setSurface(value as SurfaceChoice)
|
||||
}}
|
||||
>
|
||||
<PickerRadioItem value="solid" closeOnClick={isMobile}>
|
||||
Solid
|
||||
</PickerRadioItem>
|
||||
<PickerRadioItem value="translucent" closeOnClick={isMobile}>
|
||||
Translucent
|
||||
</PickerRadioItem>
|
||||
</PickerRadioGroup>
|
||||
</PickerGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
<LockButton
|
||||
param="menuColor"
|
||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
apps/v4/app/(app)/(create)/components/mode-switcher.tsx
Normal file
87
apps/v4/app/(app)/(create)/components/mode-switcher.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Script from "next/script"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { useThemeToggle } from "@/app/(app)/(create)/hooks/use-theme-toggle"
|
||||
|
||||
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
|
||||
|
||||
export function ModeSwitcher({
|
||||
variant = "ghost",
|
||||
className,
|
||||
}: {
|
||||
variant?: React.ComponentProps<typeof Button>["variant"]
|
||||
className?: React.ComponentProps<typeof Button>["className"]
|
||||
}) {
|
||||
const { toggleTheme } = useThemeToggle()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size="icon"
|
||||
className={cn("group/toggle extend-touch-target", className)}
|
||||
onClick={toggleTheme}
|
||||
id="mode-switcher-button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4.5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||
<path d="M12 3l0 18" />
|
||||
<path d="M12 9l4.65 -4.65" />
|
||||
<path d="M12 14.3l7.37 -7.37" />
|
||||
<path d="M12 19.6l8.85 -8.85" />
|
||||
</svg>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function DarkModeScript() {
|
||||
return (
|
||||
<Script
|
||||
id="dark-mode-listener"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
// Forward D key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.key === 'd' || e.key === 'D') && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: '${DARK_MODE_FORWARD_TYPE}',
|
||||
key: e.key
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
203
apps/v4/app/(app)/(create)/components/open-preset.tsx
Normal file
203
apps/v4/app/(app)/(create)/components/open-preset.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Script from "next/script"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/styles/base-nova/ui/dialog"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-nova/ui/drawer"
|
||||
import { Field, FieldContent, FieldLabel } from "@/styles/base-nova/ui/field"
|
||||
import { Input } from "@/styles/base-nova/ui/input"
|
||||
import {
|
||||
OPEN_PRESET_FORWARD_TYPE,
|
||||
useOpenPreset,
|
||||
} from "@/app/(app)/(create)/hooks/use-open-preset"
|
||||
import { parsePresetInput } from "@/app/(app)/(create)/lib/parse-preset-input"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
const PRESET_EXAMPLE = "b2D0wqNxT"
|
||||
const PRESET_TITLE = "Open Preset"
|
||||
const PRESET_DESCRIPTION = "Paste a preset code to load a saved configuration."
|
||||
|
||||
export function OpenPreset({
|
||||
className,
|
||||
label = "Open Preset",
|
||||
}: React.ComponentProps<typeof Button> & {
|
||||
label?: string
|
||||
}) {
|
||||
const [input, setInput] = React.useState("")
|
||||
const [, setParams] = useDesignSystemSearchParams()
|
||||
const isMobile = useIsMobile()
|
||||
const { open, setOpen } = useOpenPreset()
|
||||
|
||||
const nextPreset = React.useMemo(() => parsePresetInput(input), [input])
|
||||
const isInvalid = input.trim().length > 0 && nextPreset === null
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
|
||||
if (!nextOpen) {
|
||||
setInput("")
|
||||
}
|
||||
},
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!nextPreset) {
|
||||
return
|
||||
}
|
||||
|
||||
setParams({ preset: nextPreset })
|
||||
handleOpenChange(false)
|
||||
},
|
||||
[handleOpenChange, nextPreset, setParams]
|
||||
)
|
||||
|
||||
const triggerClassName = cn(
|
||||
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
||||
className
|
||||
)
|
||||
|
||||
const desktopTrigger = (
|
||||
<Button variant="outline" className={triggerClassName} />
|
||||
)
|
||||
|
||||
const fields = (
|
||||
<Field data-invalid={isInvalid || undefined}>
|
||||
<FieldLabel htmlFor="preset-code" className="sr-only">
|
||||
Preset code
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="preset-code"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder={`${PRESET_EXAMPLE} or --preset ${PRESET_EXAMPLE}`}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={isInvalid}
|
||||
className="h-10 md:h-8"
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerTrigger
|
||||
render={<Button variant="outline" className={triggerClassName} />}
|
||||
>
|
||||
{label}
|
||||
</DrawerTrigger>
|
||||
|
||||
<DrawerContent className="dark rounded-t-2xl!">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
|
||||
<DrawerDescription>{PRESET_DESCRIPTION}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-4 py-2">{fields}</div>
|
||||
<DrawerFooter>
|
||||
<Button type="submit" className="h-10" disabled={!nextPreset}>
|
||||
Open
|
||||
</Button>
|
||||
<DrawerClose
|
||||
render={
|
||||
<Button variant="outline" type="button" className="h-10" />
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
|
||||
<DialogContent className="dark">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{PRESET_TITLE}</DialogTitle>
|
||||
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{fields}</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" type="button" />}>
|
||||
Cancel
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={!nextPreset}>
|
||||
Open
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpenPresetScript() {
|
||||
return (
|
||||
<Script
|
||||
id="open-preset-listener"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
// Forward O key.
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: '${OPEN_PRESET_FORWARD_TYPE}',
|
||||
key: e.key
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
293
apps/v4/app/(app)/(create)/components/picker.tsx
Normal file
293
apps/v4/app/(app)/(create)/components/picker.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/registry/bases/base/lib/utils"
|
||||
import { IconPlaceholder } from "@/app/(app)/(create)/components/icon-placeholder"
|
||||
|
||||
function Picker({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function PickerPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function PickerTrigger({ className, ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
className={cn(
|
||||
"relative w-36 shrink-0 touch-manipulation rounded-xl p-3 ring-1 ring-foreground/10 select-none hover:bg-muted focus-visible:ring-foreground/50 focus-visible:outline-none disabled:opacity-50 data-popup-open:bg-muted md:w-full md:rounded-lg md:px-2.5 md:py-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 20,
|
||||
anchor,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
anchor={anchor}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"cn-menu-target z-50 no-scrollbar max-h-(--available-height) w-[calc(var(--available-width)-(--spacing(6)))] min-w-32 origin-(--transform-origin) translate-y-2 overflow-x-hidden overflow-y-auto rounded-xl border-0 bg-neutral-950/80 p-1.5 text-neutral-100 ring-1 ring-neutral-950/80 backdrop-blur-xl outline-none md:w-52 dark:bg-neutral-800/90 dark:ring-neutral-700/50 data-closed:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
<div className="absolute inset-y-0 right-0 left-62 z-40 bg-transparent" />
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function PickerLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-xs font-medium text-neutral-400 data-inset:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium outline-hidden select-none **:text-neutral-100 focus:bg-neutral-600 focus:text-neutral-100 focus:**:text-neutral-100 data-inset:pl-8 dark:focus:bg-neutral-700/80 pointer-coarse:gap-3 pointer-coarse:py-2.5 pointer-coarse:pl-3 pointer-coarse:text-base data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function PickerSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent/95 focus:text-accent-foreground focus:ring-1 focus:ring-foreground/20 not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-open:bg-accent/95 data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<IconPlaceholder
|
||||
lucide="ChevronRightIcon"
|
||||
tabler="IconChevronRight"
|
||||
hugeicons="ArrowRight01Icon"
|
||||
phosphor="CaretRightIcon"
|
||||
remixicon="RiArrowRightSLine"
|
||||
className="ml-auto"
|
||||
/>
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PickerContent>) {
|
||||
return (
|
||||
<PickerContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"w-auto min-w-[96px] rounded-md bg-popover/90 p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 backdrop-blur-xs duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent/95 focus:text-accent-foreground focus:ring-1 focus:ring-foreground/20 focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<IconPlaceholder
|
||||
lucide="CheckIcon"
|
||||
tabler="IconCheck"
|
||||
hugeicons="Tick02Icon"
|
||||
phosphor="CheckIcon"
|
||||
remixicon="RiCheckLine"
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 text-sm font-medium outline-hidden select-none **:text-neutral-100 focus:bg-neutral-600 focus:text-neutral-100 focus:**:text-neutral-100 data-inset:pl-8 dark:focus:bg-neutral-700/80 pointer-coarse:gap-3 pointer-coarse:py-2.5 pointer-coarse:pl-3 pointer-coarse:text-base data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<IconPlaceholder
|
||||
lucide="CheckIcon"
|
||||
tabler="IconCheck"
|
||||
hugeicons="Tick02Icon"
|
||||
phosphor="CheckIcon"
|
||||
remixicon="RiCheckLine"
|
||||
className="size-4 pointer-coarse:size-5"
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn(
|
||||
"-mx-1.5 my-1.5 h-px bg-neutral-600 dark:bg-neutral-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-neutral-400! group-focus/dropdown-menu-item:text-neutral-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Picker,
|
||||
PickerPortal,
|
||||
PickerTrigger,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerLabel,
|
||||
PickerItem,
|
||||
PickerCheckboxItem,
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerSeparator,
|
||||
PickerShortcut,
|
||||
PickerSub,
|
||||
PickerSubTrigger,
|
||||
PickerSubContent,
|
||||
}
|
||||
38
apps/v4/app/(app)/(create)/components/preset-handler.tsx
Normal file
38
apps/v4/app/(app)/(create)/components/preset-handler.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { generateRandomPreset, isPresetCode } from "shadcn/preset"
|
||||
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
export function PresetHandler() {
|
||||
const router = useRouter()
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
const hasConverted = React.useRef(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (params.preset === "random") {
|
||||
router.replace(`/create?preset=${generateRandomPreset()}`)
|
||||
}
|
||||
}, [params.preset, router])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasConverted.current) {
|
||||
return
|
||||
}
|
||||
hasConverted.current = true
|
||||
|
||||
if (!params.preset || params.preset === "random") {
|
||||
return
|
||||
}
|
||||
|
||||
if (isPresetCode(params.preset)) {
|
||||
return
|
||||
}
|
||||
|
||||
setParams({ base: params.base })
|
||||
}, [params.preset, params.base, setParams])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(create)/lib/search-params"
|
||||
} from "@/app/(app)/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
export function PresetPicker({
|
||||
presets,
|
||||
@@ -31,8 +31,10 @@ export function PresetPicker({
|
||||
preset.style === params.style &&
|
||||
preset.baseColor === params.baseColor &&
|
||||
preset.theme === params.theme &&
|
||||
preset.chartColor === params.chartColor &&
|
||||
preset.iconLibrary === params.iconLibrary &&
|
||||
preset.font === params.font &&
|
||||
preset.fontHeading === params.fontHeading &&
|
||||
preset.menuAccent === params.menuAccent &&
|
||||
preset.menuColor === params.menuColor &&
|
||||
preset.radius === params.radius
|
||||
@@ -43,8 +45,10 @@ export function PresetPicker({
|
||||
params.style,
|
||||
params.baseColor,
|
||||
params.theme,
|
||||
params.chartColor,
|
||||
params.iconLibrary,
|
||||
params.font,
|
||||
params.fontHeading,
|
||||
params.menuAccent,
|
||||
params.menuColor,
|
||||
params.radius,
|
||||
@@ -67,8 +71,10 @@ export function PresetPicker({
|
||||
style: preset.style,
|
||||
baseColor: preset.baseColor,
|
||||
theme: preset.theme,
|
||||
chartColor: preset.chartColor,
|
||||
iconLibrary: preset.iconLibrary,
|
||||
font: preset.font,
|
||||
fontHeading: preset.fontHeading,
|
||||
menuAccent: preset.menuAccent,
|
||||
menuColor: preset.menuColor,
|
||||
radius: preset.radius,
|
||||
@@ -80,8 +86,8 @@ export function PresetPicker({
|
||||
<Picker>
|
||||
<PickerTrigger>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-muted-foreground text-xs">Preset</div>
|
||||
<div className="text-foreground line-clamp-1 text-sm font-medium">
|
||||
<div className="text-xs text-muted-foreground">Preset</div>
|
||||
<div className="line-clamp-1 text-sm font-medium text-foreground">
|
||||
{currentPreset?.description ?? "Custom"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,15 +106,12 @@ export function PresetPicker({
|
||||
{currentBasePresets.map((preset) => {
|
||||
const style = STYLES.find((s) => s.name === preset.style)
|
||||
return (
|
||||
<PickerRadioItem key={preset.title} value={preset.title}>
|
||||
<PickerRadioItem
|
||||
key={preset.title}
|
||||
value={preset.title}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{style?.icon && (
|
||||
<div className="flex size-4 shrink-0 items-center justify-center">
|
||||
{React.cloneElement(style.icon, {
|
||||
className: "size-4",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{preset.description}
|
||||
</div>
|
||||
</PickerRadioItem>
|
||||
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>
|
||||
)
|
||||
}
|
||||
168
apps/v4/app/(app)/(create)/components/preview.tsx
Normal file
168
apps/v4/app/(app)/(create)/components/preview.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/(create)/components/action-menu"
|
||||
import { CreateDevtools } from "@/app/(app)/(create)/components/create-devtools"
|
||||
import {
|
||||
REDO_FORWARD_TYPE,
|
||||
UNDO_FORWARD_TYPE,
|
||||
} from "@/app/(app)/(create)/components/history-buttons"
|
||||
import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/(create)/components/mode-switcher"
|
||||
import { PreviewSwitcher } from "@/app/(app)/(create)/components/preview-switcher"
|
||||
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/(create)/components/random-button"
|
||||
import { sendToIframe } from "@/app/(app)/(create)/hooks/use-iframe-sync"
|
||||
import { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/(create)/hooks/use-open-preset"
|
||||
import { RESET_FORWARD_TYPE } from "@/app/(app)/(create)/hooks/use-reset"
|
||||
import {
|
||||
serializeDesignSystemSearchParams,
|
||||
useDesignSystemSearchParams,
|
||||
} from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
|
||||
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
|
||||
|
||||
export function Preview() {
|
||||
const [params] = useDesignSystemSearchParams()
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) {
|
||||
return
|
||||
}
|
||||
|
||||
const sendParams = () => {
|
||||
sendToIframe(iframe, "design-system-params", params)
|
||||
}
|
||||
|
||||
if (iframe.contentWindow) {
|
||||
sendParams()
|
||||
}
|
||||
|
||||
iframe.addEventListener("load", sendParams)
|
||||
return () => {
|
||||
iframe.removeEventListener("load", sendParams)
|
||||
}
|
||||
}, [params])
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const iframeWindow = iframeRef.current?.contentWindow
|
||||
if (
|
||||
!iframeWindow ||
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== iframeWindow ||
|
||||
!event.data ||
|
||||
typeof event.data !== "object"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const type = event.data.type
|
||||
if (type === CMD_K_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "k",
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === RANDOMIZE_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "r",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === OPEN_PRESET_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "o",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === UNDO_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === REDO_FORWARD_TYPE) {
|
||||
const isMac = MAC_REGEX.test(navigator.userAgent)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
shiftKey: true,
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === RESET_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "R",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
} else if (type === DARK_MODE_FORWARD_TYPE) {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: event.data.key || "d",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const iframeSrc = React.useMemo(() => {
|
||||
// The iframe src needs to include the serialized design system params
|
||||
// for the initial load, but not be reactive to them as it would cause
|
||||
// full-iframe reloads on every param change (flashes & loss of state).
|
||||
// Further updates of the search params will be sent to the iframe
|
||||
// via a postMessage channel, for it to sync its own history onto the host's.
|
||||
return serializeDesignSystemSearchParams(
|
||||
`/preview/${params.base}/${params.item}`,
|
||||
params
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.base, params.item])
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col justify-center overflow-hidden rounded-2xl ring ring-foreground/10 md:ring-muted dark:ring-foreground/10">
|
||||
<div className="relative z-0 mx-auto flex w-full flex-1 flex-col overflow-hidden">
|
||||
<div className="absolute inset-0 bg-muted dark:bg-muted/30" />
|
||||
<iframe
|
||||
key={params.base + params.item}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
className="z-10 size-full flex-1"
|
||||
title="Preview"
|
||||
/>
|
||||
</div>
|
||||
<CreateDevtools />
|
||||
<PreviewSwitcher />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
726
apps/v4/app/(app)/(create)/components/project-form.tsx
Normal file
726
apps/v4/app/(app)/(create)/components/project-form.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Copy01Icon,
|
||||
Globe02Icon,
|
||||
HandPointingRight04Icon,
|
||||
Tick02Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||
import {
|
||||
BASES,
|
||||
buildThemeForPreset,
|
||||
DEFAULT_CONFIG,
|
||||
type BaseName,
|
||||
type DesignSystemConfig,
|
||||
} from "@/registry/config"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "@/styles/base-nova/ui/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldTitle,
|
||||
} from "@/styles/base-nova/ui/field"
|
||||
import { RadioGroup, RadioGroupItem } from "@/styles/base-nova/ui/radio-group"
|
||||
import { Switch } from "@/styles/base-nova/ui/switch"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/styles/base-nova/ui/tabs"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/styles/base-nova/ui/toggle-group"
|
||||
import { usePresetCode } from "@/app/(app)/(create)/hooks/use-design-system"
|
||||
import {
|
||||
useDesignSystemSearchParams,
|
||||
type DesignSystemSearchParams,
|
||||
} from "@/app/(app)/(create)/lib/search-params"
|
||||
import {
|
||||
getFramework,
|
||||
getTemplateValue,
|
||||
NO_MONOREPO_FRAMEWORKS,
|
||||
TEMPLATES,
|
||||
} from "@/app/(app)/(create)/lib/templates"
|
||||
|
||||
const TURBOREPO_LOGO =
|
||||
'<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Turborepo</title><path d="M11.9906 4.1957c-4.2998 0-7.7981 3.501-7.7981 7.8043s3.4983 7.8043 7.7981 7.8043c4.2999 0 7.7982-3.501 7.7982-7.8043s-3.4983-7.8043-7.7982-7.8043m0 11.843c-2.229 0-4.0356-1.8079-4.0356-4.0387s1.8065-4.0387 4.0356-4.0387S16.0262 9.7692 16.0262 12s-1.8065 4.0388-4.0356 4.0388m.6534-13.1249V0C18.9726.3386 24 5.5822 24 12s-5.0274 11.66-11.356 12v-2.9139c4.7167-.3372 8.4516-4.2814 8.4516-9.0861s-3.735-8.749-8.4516-9.0861M5.113 17.9586c-1.2502-1.4446-2.0562-3.2845-2.2-5.3046H0c.151 2.8266 1.2808 5.3917 3.051 7.3668l2.0606-2.0622zM11.3372 24v-2.9139c-2.02-.1439-3.8584-.949-5.3019-2.2018l-2.0606 2.0623c1.975 1.773 4.538 2.9022 7.361 3.0534z"/></svg>'
|
||||
const ORIGIN = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:4000"
|
||||
const IS_LOCAL_DEV = ORIGIN.includes("localhost")
|
||||
const SHADCN_VERSION = process.env.NEXT_PUBLIC_RC ? "@rc" : "@latest"
|
||||
const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"] as const
|
||||
type PackageManager = (typeof PACKAGE_MANAGERS)[number]
|
||||
|
||||
const APPLY_MODES = [
|
||||
{
|
||||
value: "full",
|
||||
title: "Full preset",
|
||||
description:
|
||||
"Everything from the preset, including components, theme, and fonts.",
|
||||
flag: null,
|
||||
label: "full preset",
|
||||
},
|
||||
{
|
||||
value: "theme",
|
||||
title: "Theme only",
|
||||
description:
|
||||
"Theme tokens only, like colors, radii, and shadows. Components stay as they are.",
|
||||
flag: "--only theme",
|
||||
label: "--only theme",
|
||||
},
|
||||
{
|
||||
value: "font",
|
||||
title: "Fonts only",
|
||||
description:
|
||||
"Only preset fonts for body and headings. Components stay as they are.",
|
||||
flag: "--only font",
|
||||
label: "--only font",
|
||||
},
|
||||
] as const
|
||||
type ApplyMode = (typeof APPLY_MODES)[number]["value"]
|
||||
type ProjectFormTab = "new-project" | "existing-project" | "theme"
|
||||
type CopyTarget = "command" | "apply" | "theme"
|
||||
type ThemeCssVars = NonNullable<
|
||||
ReturnType<typeof buildThemeForPreset>["cssVars"]
|
||||
>
|
||||
|
||||
function formatCssVarsRule(selector: string, cssVars?: Record<string, string>) {
|
||||
const declarations = Object.entries(cssVars ?? {})
|
||||
.map(([key, value]) => ` --${key}: ${value};`)
|
||||
.join("\n")
|
||||
|
||||
return `${selector} {\n${declarations}\n}`
|
||||
}
|
||||
|
||||
function formatThemeCss(cssVars: ThemeCssVars) {
|
||||
return [
|
||||
formatCssVarsRule(":root", cssVars.light),
|
||||
formatCssVarsRule(".dark", cssVars.dark),
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
export function ProjectForm({
|
||||
className,
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
const presetCode = usePresetCode()
|
||||
const [config, setConfig] = useConfig()
|
||||
const [copiedTarget, setCopiedTarget] = React.useState<CopyTarget | null>(
|
||||
null
|
||||
)
|
||||
const [applyMode, setApplyMode] = React.useState<ApplyMode>("full")
|
||||
const [activeTab, setActiveTab] =
|
||||
React.useState<ProjectFormTab>("new-project")
|
||||
|
||||
const packageManager = (config.packageManager || "pnpm") as PackageManager
|
||||
const framework = React.useMemo(
|
||||
() => getFramework(params.template ?? "next"),
|
||||
[params.template]
|
||||
)
|
||||
const isMonorepo = React.useMemo(
|
||||
() => params.template?.endsWith("-monorepo") ?? false,
|
||||
[params.template]
|
||||
)
|
||||
|
||||
const hasMonorepo = !NO_MONOREPO_FRAMEWORKS.includes(
|
||||
framework as (typeof NO_MONOREPO_FRAMEWORKS)[number]
|
||||
)
|
||||
|
||||
const commands = React.useMemo(() => {
|
||||
const presetFlag = ` --preset ${presetCode}`
|
||||
const baseFlag =
|
||||
params.base !== DEFAULT_CONFIG.base ? ` --base ${params.base}` : ""
|
||||
const templateFlag = ` --template ${framework}`
|
||||
const monorepoFlag = isMonorepo ? " --monorepo" : ""
|
||||
const rtlFlag = params.rtl ? " --rtl" : ""
|
||||
const pointerFlag = params.pointer ? " --pointer" : ""
|
||||
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}${pointerFlag}`
|
||||
|
||||
return IS_LOCAL_DEV
|
||||
? {
|
||||
pnpm: `shadcn init${flags}`,
|
||||
npm: `shadcn init${flags}`,
|
||||
yarn: `shadcn init${flags}`,
|
||||
bun: `shadcn init${flags}`,
|
||||
}
|
||||
: {
|
||||
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} init${flags}`,
|
||||
npm: `npx shadcn${SHADCN_VERSION} init${flags}`,
|
||||
yarn: `yarn dlx shadcn${SHADCN_VERSION} init${flags}`,
|
||||
bun: `bunx --bun shadcn${SHADCN_VERSION} init${flags}`,
|
||||
}
|
||||
}, [
|
||||
framework,
|
||||
isMonorepo,
|
||||
params.base,
|
||||
params.pointer,
|
||||
params.rtl,
|
||||
presetCode,
|
||||
])
|
||||
|
||||
const command = commands[packageManager]
|
||||
|
||||
const applyCommands = React.useMemo(() => {
|
||||
const presetFlag = ` --preset ${presetCode}`
|
||||
const onlyFlag =
|
||||
applyMode === "theme"
|
||||
? " --only theme"
|
||||
: applyMode === "font"
|
||||
? " --only font"
|
||||
: ""
|
||||
const flags = `${presetFlag}${onlyFlag}`
|
||||
|
||||
return IS_LOCAL_DEV
|
||||
? {
|
||||
pnpm: `shadcn apply${flags}`,
|
||||
npm: `shadcn apply${flags}`,
|
||||
yarn: `shadcn apply${flags}`,
|
||||
bun: `shadcn apply${flags}`,
|
||||
}
|
||||
: {
|
||||
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} apply${flags}`,
|
||||
npm: `npx shadcn${SHADCN_VERSION} apply${flags}`,
|
||||
yarn: `yarn dlx shadcn${SHADCN_VERSION} apply${flags}`,
|
||||
bun: `bunx --bun shadcn${SHADCN_VERSION} apply${flags}`,
|
||||
}
|
||||
}, [applyMode, presetCode])
|
||||
|
||||
const applyCommand = applyCommands[packageManager]
|
||||
const themeConfig = React.useMemo<DesignSystemConfig>(() => {
|
||||
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
|
||||
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
base: params.base,
|
||||
style: params.style,
|
||||
baseColor: params.baseColor,
|
||||
theme: params.theme,
|
||||
chartColor: params.chartColor,
|
||||
iconLibrary: params.iconLibrary,
|
||||
font: params.font,
|
||||
fontHeading: params.fontHeading,
|
||||
menuAccent: params.menuAccent,
|
||||
menuColor: params.menuColor,
|
||||
radius: isRadiusLocked ? "none" : params.radius,
|
||||
template: params.template,
|
||||
rtl: params.rtl,
|
||||
pointer: params.pointer,
|
||||
}
|
||||
}, [
|
||||
params.base,
|
||||
params.baseColor,
|
||||
params.chartColor,
|
||||
params.font,
|
||||
params.fontHeading,
|
||||
params.iconLibrary,
|
||||
params.menuAccent,
|
||||
params.menuColor,
|
||||
params.pointer,
|
||||
params.radius,
|
||||
params.rtl,
|
||||
params.style,
|
||||
params.template,
|
||||
params.theme,
|
||||
])
|
||||
|
||||
const themeCss = React.useMemo(() => {
|
||||
const theme = buildThemeForPreset(themeConfig)
|
||||
|
||||
if (!theme.cssVars) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatThemeCss(theme.cssVars)
|
||||
}, [themeConfig])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (copiedTarget) {
|
||||
const timer = setTimeout(() => setCopiedTarget(null), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [copiedTarget])
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
const properties: Record<string, string> = {
|
||||
command,
|
||||
}
|
||||
if (params.template) {
|
||||
properties.template = params.template
|
||||
}
|
||||
copyToClipboardWithMeta(command, {
|
||||
name: "copy_npm_command",
|
||||
properties,
|
||||
})
|
||||
setCopiedTarget("command")
|
||||
}, [command, params.template])
|
||||
|
||||
const handleCopyApply = React.useCallback(() => {
|
||||
copyToClipboardWithMeta(applyCommand, {
|
||||
name: "copy_apply_command",
|
||||
properties: {
|
||||
command: applyCommand,
|
||||
applyMode,
|
||||
},
|
||||
})
|
||||
setCopiedTarget("apply")
|
||||
}, [applyCommand, applyMode])
|
||||
|
||||
const handleCopyTheme = React.useCallback(() => {
|
||||
copyToClipboardWithMeta(themeCss, {
|
||||
name: "copy_theme_code",
|
||||
properties: {
|
||||
preset: presetCode,
|
||||
baseColor: params.baseColor,
|
||||
theme: params.theme,
|
||||
format: "css",
|
||||
},
|
||||
})
|
||||
setCopiedTarget("theme")
|
||||
}, [params.baseColor, params.theme, presetCode, themeCss])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button className={cn(className)} />}>
|
||||
Get Code
|
||||
</DialogTrigger>
|
||||
<DialogContent className="dark top-[64px] no-scrollbar flex max-h-[calc(100svh-2rem)] translate-y-0 flex-col rounded-2xl p-0 shadow-xl **:data-[slot=dialog-close]:top-4.5 **:data-[slot=dialog-close]:right-4 **:data-[slot=field-separator]:h-2 sm:max-w-md">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0 overflow-hidden rounded-2xl">
|
||||
<DialogHeader className="border-b px-6 py-5">
|
||||
<ToggleGroup
|
||||
value={[activeTab]}
|
||||
onValueChange={(values) =>
|
||||
setActiveTab((values[0] as typeof activeTab) ?? "new-project")
|
||||
}
|
||||
aria-label="Project type"
|
||||
spacing={2}
|
||||
className="**:data-[slot=toggle-group-item]:data-pressed:bg-neutral-700/70"
|
||||
>
|
||||
<ToggleGroupItem value="new-project">New Project</ToggleGroupItem>
|
||||
<ToggleGroupItem value="existing-project">
|
||||
Existing Project
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="theme">Theme</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</DialogHeader>
|
||||
{activeTab === "new-project" && (
|
||||
<div className="no-scrollbar overflow-y-auto">
|
||||
<FieldGroup className="px-6 py-4">
|
||||
<Field className="gap-3">
|
||||
<FieldLabel>Template</FieldLabel>
|
||||
<TemplateGrid
|
||||
template={params.template}
|
||||
setParams={setParams}
|
||||
/>
|
||||
</Field>
|
||||
<FieldSeparator className="-mx-6" />
|
||||
<Field>
|
||||
<FieldLabel>Base</FieldLabel>
|
||||
<BaseGrid base={params.base} setParams={setParams} />
|
||||
</Field>
|
||||
<FieldSeparator className="-mx-6" />
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label" className="sr-only">
|
||||
Options
|
||||
</FieldLegend>
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="pointer">
|
||||
<HugeiconsIcon
|
||||
icon={HandPointingRight04Icon}
|
||||
className="size-4 -rotate-90"
|
||||
/>
|
||||
Use pointer on buttons
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id="pointer"
|
||||
checked={params.pointer}
|
||||
onCheckedChange={(checked) =>
|
||||
setParams({ pointer: checked === true })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<FieldSeparator className="-mx-6" />
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
data-disabled={hasMonorepo ? undefined : "true"}
|
||||
>
|
||||
<FieldLabel htmlFor="monorepo">
|
||||
<span
|
||||
className="size-4 text-neutral-100 [&_svg]:size-4 [&_svg]:fill-current"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: TURBOREPO_LOGO,
|
||||
}}
|
||||
/>
|
||||
Create a monorepo
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id="monorepo"
|
||||
checked={params.template?.endsWith("-monorepo") ?? false}
|
||||
disabled={!hasMonorepo}
|
||||
onCheckedChange={(checked) => {
|
||||
const framework = getFramework(
|
||||
params.template ?? "next"
|
||||
)
|
||||
setParams({
|
||||
template: getTemplateValue(
|
||||
framework,
|
||||
checked === true
|
||||
) as typeof params.template,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<FieldSeparator className="-mx-6" />
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="rtl">
|
||||
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
|
||||
Enable RTL support
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id="rtl"
|
||||
checked={params.rtl}
|
||||
onCheckedChange={(checked) =>
|
||||
setParams({ rtl: checked === true })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="m-0 min-w-0 p-6">
|
||||
<div className="flex w-full min-w-0 flex-col gap-3">
|
||||
<Tabs
|
||||
value={packageManager}
|
||||
onValueChange={(value) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
packageManager: value as PackageManager,
|
||||
}))
|
||||
}}
|
||||
className="min-w-0 gap-0 overflow-hidden rounded-xl border-0 ring-1 ring-border"
|
||||
>
|
||||
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
|
||||
<TabsList className="bg-transparent font-mono">
|
||||
{PACKAGE_MANAGERS.map((manager) => {
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={manager}
|
||||
value={manager}
|
||||
className="py-0 leading-none data-[state=active]:shadow-none"
|
||||
>
|
||||
{manager}
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copiedTarget === "command" ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Copy01Icon} />
|
||||
)}
|
||||
<span className="sr-only">Copy command</span>
|
||||
</Button>
|
||||
</div>
|
||||
{Object.entries(commands).map(([key, cmd]) => {
|
||||
return (
|
||||
<TabsContent key={key} value={key}>
|
||||
<div className="relative overflow-hidden border-t bg-popover p-3">
|
||||
<div className="no-scrollbar overflow-x-auto">
|
||||
<code className="font-mono text-sm whitespace-nowrap">
|
||||
{cmd}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
<Button onClick={handleCopy} className="h-9 w-full">
|
||||
{copiedTarget === "command" ? "Copied" : "Copy Command"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "existing-project" && (
|
||||
<div className="no-scrollbar overflow-y-auto">
|
||||
<FieldGroup className="px-6 py-4">
|
||||
<FieldSet className="gap-3">
|
||||
<FieldLegend variant="label">Apply Preset</FieldLegend>
|
||||
<FieldDescription>
|
||||
Pick which parts of the preset to apply.
|
||||
</FieldDescription>
|
||||
<ApplyModeGrid mode={applyMode} setMode={setApplyMode} />
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="m-0 min-w-0 p-6">
|
||||
<div className="flex w-full min-w-0 flex-col gap-3">
|
||||
<Tabs
|
||||
value={packageManager}
|
||||
onValueChange={(value) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
packageManager: value as PackageManager,
|
||||
}))
|
||||
}}
|
||||
className="min-w-0 gap-0 overflow-hidden rounded-xl border-0 ring-1 ring-border"
|
||||
>
|
||||
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
|
||||
<TabsList className="bg-transparent font-mono">
|
||||
{PACKAGE_MANAGERS.map((manager) => {
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={manager}
|
||||
value={manager}
|
||||
className="py-0 leading-none data-[state=active]:shadow-none"
|
||||
>
|
||||
{manager}
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
onClick={handleCopyApply}
|
||||
>
|
||||
{copiedTarget === "apply" ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Copy01Icon} />
|
||||
)}
|
||||
<span className="sr-only">Copy command</span>
|
||||
</Button>
|
||||
</div>
|
||||
{Object.entries(applyCommands).map(([key, cmd]) => {
|
||||
return (
|
||||
<TabsContent key={key} value={key}>
|
||||
<div className="relative overflow-hidden border-t bg-popover p-3">
|
||||
<div className="no-scrollbar overflow-x-auto">
|
||||
<code className="font-mono text-sm whitespace-nowrap">
|
||||
{cmd}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
<Button onClick={handleCopyApply} className="h-9 w-full">
|
||||
{copiedTarget === "apply" ? "Copied" : "Copy Command"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "theme" && (
|
||||
<div className="no-scrollbar overflow-y-auto">
|
||||
<FieldGroup className="min-w-0 px-6 py-4">
|
||||
<FieldSet className="min-w-0 gap-3">
|
||||
<FieldLegend variant="label">Theme Tokens</FieldLegend>
|
||||
<FieldDescription>
|
||||
Copy the CSS variables for this preset.
|
||||
</FieldDescription>
|
||||
<div className="w-full min-w-0 overflow-hidden rounded-xl border-0 ring-1 ring-border">
|
||||
<div className="flex items-center gap-2 py-1 pr-1.5 pl-3">
|
||||
<div className="min-w-0 truncate font-mono text-sm text-muted-foreground">
|
||||
globals.css
|
||||
</div>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
onClick={handleCopyTheme}
|
||||
>
|
||||
{copiedTarget === "theme" ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Copy01Icon} />
|
||||
)}
|
||||
<span className="sr-only">Copy theme</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative no-scrollbar max-h-[45svh] overflow-auto border-t bg-popover p-3">
|
||||
<pre className="min-w-max font-mono leading-normal whitespace-pre">
|
||||
<code>{themeCss}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="m-0 min-w-0 p-6">
|
||||
<Button onClick={handleCopyTheme} className="h-9 w-full">
|
||||
{copiedTarget === "theme" ? "Copied" : "Copy Theme"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const TemplateGrid = React.memo(function TemplateGrid({
|
||||
template,
|
||||
setParams,
|
||||
}: {
|
||||
template: DesignSystemSearchParams["template"]
|
||||
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
|
||||
}) {
|
||||
const isMonorepo = template?.endsWith("-monorepo") ?? false
|
||||
const framework = getFramework(template ?? "next")
|
||||
|
||||
const handleTemplateChange = React.useCallback(
|
||||
(value: string) => {
|
||||
setParams({
|
||||
template: getTemplateValue(
|
||||
value,
|
||||
isMonorepo
|
||||
) as DesignSystemSearchParams["template"],
|
||||
})
|
||||
},
|
||||
[isMonorepo, setParams]
|
||||
)
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
value={framework}
|
||||
onValueChange={handleTemplateChange}
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
{TEMPLATES.map((item) => (
|
||||
<FieldLabel
|
||||
key={item.value}
|
||||
htmlFor={`template-${item.value}`}
|
||||
className="block w-full"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
|
||||
>
|
||||
<FieldContent className="flex flex-row items-center gap-2 px-2.5 py-1.5">
|
||||
<div
|
||||
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.logo,
|
||||
}}
|
||||
></div>
|
||||
<FieldTitle>{item.title}</FieldTitle>
|
||||
</FieldContent>
|
||||
<RadioGroupItem
|
||||
value={item.value}
|
||||
id={`template-${item.value}`}
|
||||
className="sr-only absolute"
|
||||
/>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
})
|
||||
|
||||
const ApplyModeGrid = React.memo(function ApplyModeGrid({
|
||||
mode,
|
||||
setMode,
|
||||
}: {
|
||||
mode: ApplyMode
|
||||
setMode: (mode: ApplyMode) => void
|
||||
}) {
|
||||
return (
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value) => setMode(value as ApplyMode)}
|
||||
aria-label="Apply"
|
||||
>
|
||||
{APPLY_MODES.map((option) => (
|
||||
<FieldLabel key={option.value} htmlFor={`apply-${option.value}`}>
|
||||
<Field orientation="horizontal">
|
||||
<RadioGroupItem value={option.value} id={`apply-${option.value}`} />
|
||||
<FieldContent>
|
||||
<FieldTitle>{option.title}</FieldTitle>
|
||||
<FieldDescription>{option.description}</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
})
|
||||
|
||||
const BaseGrid = React.memo(function BaseGrid({
|
||||
base,
|
||||
setParams,
|
||||
}: {
|
||||
base: DesignSystemSearchParams["base"]
|
||||
setParams: ReturnType<typeof useDesignSystemSearchParams>[1]
|
||||
}) {
|
||||
const handleBaseChange = React.useCallback(
|
||||
(value: string) => {
|
||||
setParams({ base: value as BaseName })
|
||||
},
|
||||
[setParams]
|
||||
)
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
value={base}
|
||||
onValueChange={handleBaseChange}
|
||||
aria-label="Base"
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
{BASES.map((item) => (
|
||||
<FieldLabel
|
||||
key={item.name}
|
||||
htmlFor={`base-${item.name}`}
|
||||
className="block w-full"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="w-full rounded-md transition-colors duration-150 hover:bg-neutral-700/45"
|
||||
>
|
||||
<FieldContent className="flex flex-row items-center gap-2 px-2.5 py-1.5">
|
||||
<div
|
||||
className="size-4 text-neutral-100 [&_svg]:size-4 *:[svg]:text-neutral-100!"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.meta?.logo ?? "",
|
||||
}}
|
||||
/>
|
||||
<FieldTitle>{item.title}</FieldTitle>
|
||||
</FieldContent>
|
||||
<RadioGroupItem
|
||||
value={item.name}
|
||||
id={`base-${item.name}`}
|
||||
className="sr-only absolute"
|
||||
/>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
})
|
||||
111
apps/v4/app/(app)/(create)/components/radius-picker.tsx
Normal file
111
apps/v4/app/(app)/(create)/components/radius-picker.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { RADII, type RadiusValue } 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 RadiusPicker({
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
|
||||
const selectedRadiusName = isRadiusLocked ? "none" : params.radius
|
||||
|
||||
const currentRadius = RADII.find(
|
||||
(radius) => radius.name === selectedRadiusName
|
||||
)
|
||||
const defaultRadius = RADII.find((radius) => radius.name === "default")
|
||||
const otherRadii = RADII.filter((radius) => radius.name !== "default")
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
<PickerTrigger disabled={isRadiusLocked}>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-xs text-muted-foreground">Radius</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentRadius?.label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 rotate-90 items-center justify-center text-base text-foreground select-none md:right-2.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
className="text-foreground"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 20v-5C4 8.925 8.925 4 15 4h5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</PickerTrigger>
|
||||
<PickerContent
|
||||
anchor={isMobile ? anchorRef : undefined}
|
||||
side={isMobile ? "top" : "right"}
|
||||
align={isMobile ? "center" : "start"}
|
||||
>
|
||||
<PickerRadioGroup
|
||||
value={currentRadius?.name}
|
||||
onValueChange={(value) => {
|
||||
if (isRadiusLocked) {
|
||||
return
|
||||
}
|
||||
setParams({ radius: value as RadiusValue })
|
||||
}}
|
||||
>
|
||||
<PickerGroup>
|
||||
{defaultRadius && (
|
||||
<PickerRadioItem
|
||||
key={defaultRadius.name}
|
||||
value={defaultRadius.name}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
{defaultRadius.label}
|
||||
</PickerRadioItem>
|
||||
)}
|
||||
</PickerGroup>
|
||||
<PickerSeparator />
|
||||
<PickerGroup>
|
||||
{otherRadii.map((radius) => (
|
||||
<PickerRadioItem
|
||||
key={radius.name}
|
||||
value={radius.name}
|
||||
closeOnClick={isMobile}
|
||||
disabled={params.style === "rhea" && radius.name === "large"}
|
||||
>
|
||||
{radius.label}
|
||||
</PickerRadioItem>
|
||||
))}
|
||||
</PickerGroup>
|
||||
</PickerRadioGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
<LockButton
|
||||
param="radius"
|
||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
apps/v4/app/(app)/(create)/components/random-button.tsx
Normal file
73
apps/v4/app/(app)/(create)/components/random-button.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import Script from "next/script"
|
||||
import { DiceFaces05Icon } from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { useRandom } from "@/app/(app)/(create)/hooks/use-random"
|
||||
import { RESET_FORWARD_TYPE } from "@/app/(app)/(create)/hooks/use-reset"
|
||||
|
||||
export const RANDOMIZE_FORWARD_TYPE = "randomize-forward"
|
||||
|
||||
export function RandomButton({
|
||||
variant = "outline",
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { randomize } = useRandom()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={randomize}
|
||||
className={cn(
|
||||
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="w-full truncate text-center font-medium">Shuffle</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function RandomizeScript() {
|
||||
return (
|
||||
<Script
|
||||
id="randomize-listener"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
// Forward r key (shuffle) and Shift+R (reset).
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.key === 'r' || e.key === 'R') && !e.metaKey && !e.ctrlKey) {
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (window.parent && window.parent !== window) {
|
||||
var type = e.shiftKey
|
||||
? '${RESET_FORWARD_TYPE}'
|
||||
: '${RANDOMIZE_FORWARD_TYPE}';
|
||||
window.parent.postMessage({
|
||||
type: type,
|
||||
key: e.key
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
34
apps/v4/app/(app)/(create)/components/reset-button.tsx
Normal file
34
apps/v4/app/(app)/(create)/components/reset-button.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/styles/base-nova/ui/alert-dialog"
|
||||
import { useReset } from "@/app/(app)/(create)/hooks/use-reset"
|
||||
|
||||
export function ResetDialog() {
|
||||
const { showResetDialog, setShowResetDialog, confirmReset } = useReset()
|
||||
|
||||
return (
|
||||
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset to defaults?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will reset all customization options to their default values.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmReset}>Reset</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
66
apps/v4/app/(app)/(create)/components/share-button.tsx
Normal file
66
apps/v4/app/(app)/(create)/components/share-button.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Share03Icon, Tick02Icon } from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { usePresetCode } from "@/app/(app)/(create)/hooks/use-design-system"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
export function ShareButton() {
|
||||
const [params] = useDesignSystemSearchParams()
|
||||
const presetCode = usePresetCode()
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
|
||||
const shareUrl = React.useMemo(() => {
|
||||
const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
|
||||
const searchParams = new URLSearchParams({
|
||||
preset: presetCode,
|
||||
item: params.item,
|
||||
})
|
||||
|
||||
if (params.pointer) {
|
||||
searchParams.set("pointer", "true")
|
||||
}
|
||||
|
||||
return `${origin}/create?${searchParams.toString()}`
|
||||
}, [params.item, params.pointer, presetCode])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasCopied) {
|
||||
const timer = setTimeout(() => setHasCopied(false), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [hasCopied])
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
copyToClipboardWithMeta(shareUrl, {
|
||||
name: "copy_create_share_url",
|
||||
properties: {
|
||||
url: shareUrl,
|
||||
},
|
||||
})
|
||||
setHasCopied(true)
|
||||
}, [shareUrl])
|
||||
|
||||
return (
|
||||
<Button variant="outline" className="hidden md:flex" onClick={handleCopy}>
|
||||
{hasCopied ? (
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
strokeWidth={2}
|
||||
data-icon="inline-start"
|
||||
/>
|
||||
) : (
|
||||
<HugeiconsIcon
|
||||
icon={Share03Icon}
|
||||
strokeWidth={2}
|
||||
data-icon="inline-start"
|
||||
/>
|
||||
)}
|
||||
Share
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
79
apps/v4/app/(app)/(create)/components/style-picker.tsx
Normal file
79
apps/v4/app/(app)/(create)/components/style-picker.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { type Style, type StyleName } from "@/registry/config"
|
||||
import { LockButton } from "@/app/(app)/(create)/components/lock-button"
|
||||
import {
|
||||
Picker,
|
||||
PickerContent,
|
||||
PickerGroup,
|
||||
PickerRadioGroup,
|
||||
PickerRadioItem,
|
||||
PickerTrigger,
|
||||
} from "@/app/(app)/(create)/components/picker"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
export function StylePicker({
|
||||
styles,
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
styles: readonly Style[]
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const currentStyle = styles.find((style) => style.name === params.style)
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
<PickerTrigger>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-xs text-muted-foreground">Style</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentStyle?.title}
|
||||
</div>
|
||||
</div>
|
||||
{currentStyle?.icon && (
|
||||
<div className="pointer-events-none absolute top-1/2 right-4 flex size-4 -translate-y-1/2 items-center justify-center select-none md:right-2.5">
|
||||
{React.cloneElement(currentStyle.icon, {
|
||||
className: "size-4",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PickerTrigger>
|
||||
<PickerContent
|
||||
anchor={isMobile ? anchorRef : undefined}
|
||||
side={isMobile ? "top" : "right"}
|
||||
align={isMobile ? "center" : "start"}
|
||||
>
|
||||
<PickerRadioGroup
|
||||
value={currentStyle?.name}
|
||||
onValueChange={(value) => {
|
||||
setParams({ style: value as StyleName })
|
||||
}}
|
||||
>
|
||||
<PickerGroup>
|
||||
{styles.map((style) => (
|
||||
<PickerRadioItem
|
||||
value={style.name}
|
||||
key={style.name}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
{style.title}
|
||||
</PickerRadioItem>
|
||||
))}
|
||||
</PickerGroup>
|
||||
</PickerRadioGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
<LockButton
|
||||
param="style"
|
||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
apps/v4/app/(app)/(create)/components/theme-picker.tsx
Normal file
124
apps/v4/app/(app)/(create)/components/theme-picker.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { useMounted } from "@/hooks/use-mounted"
|
||||
import { BASE_COLORS, type Theme, type ThemeName } 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 ThemePicker({
|
||||
themes,
|
||||
isMobile,
|
||||
anchorRef,
|
||||
}: {
|
||||
themes: readonly Theme[]
|
||||
isMobile: boolean
|
||||
anchorRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const mounted = useMounted()
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const currentTheme = React.useMemo(
|
||||
() => themes.find((theme) => theme.name === params.theme),
|
||||
[themes, params.theme]
|
||||
)
|
||||
|
||||
const currentThemeIsBaseColor = React.useMemo(
|
||||
() => BASE_COLORS.find((baseColor) => baseColor.name === params.theme),
|
||||
[params.theme]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
<PickerTrigger>
|
||||
<div className="flex flex-col justify-start text-left">
|
||||
<div className="text-xs text-muted-foreground">Theme</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentTheme?.title}
|
||||
</div>
|
||||
</div>
|
||||
{mounted && (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--color":
|
||||
currentTheme?.cssVars?.dark?.[
|
||||
currentThemeIsBaseColor ? "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={currentTheme?.name}
|
||||
onValueChange={(value) => {
|
||||
setParams({ theme: value as ThemeName })
|
||||
}}
|
||||
>
|
||||
<PickerGroup>
|
||||
{themes
|
||||
.filter((theme) =>
|
||||
BASE_COLORS.find((baseColor) => baseColor.name === theme.name)
|
||||
)
|
||||
.map((theme) => {
|
||||
return (
|
||||
<PickerRadioItem
|
||||
key={theme.name}
|
||||
value={theme.name}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
{theme.title}
|
||||
</PickerRadioItem>
|
||||
)
|
||||
})}
|
||||
</PickerGroup>
|
||||
<PickerSeparator />
|
||||
<PickerGroup>
|
||||
{themes
|
||||
.filter(
|
||||
(theme) =>
|
||||
!BASE_COLORS.find(
|
||||
(baseColor) => baseColor.name === theme.name
|
||||
)
|
||||
)
|
||||
.map((theme) => {
|
||||
return (
|
||||
<PickerRadioItem
|
||||
key={theme.name}
|
||||
value={theme.name}
|
||||
closeOnClick={isMobile}
|
||||
>
|
||||
{theme.title}
|
||||
</PickerRadioItem>
|
||||
)
|
||||
})}
|
||||
</PickerGroup>
|
||||
</PickerRadioGroup>
|
||||
</PickerContent>
|
||||
</Picker>
|
||||
<LockButton
|
||||
param="theme"
|
||||
className="absolute top-1/2 right-8 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
apps/v4/app/(app)/(create)/components/v0-button.tsx
Normal file
57
apps/v4/app/(app)/(create)/components/v0-button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useMounted } from "@/hooks/use-mounted"
|
||||
import { Icons } from "@/components/icons"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
export function V0Button({ className }: { className?: string }) {
|
||||
const [params] = useDesignSystemSearchParams()
|
||||
const isMobile = useIsMobile()
|
||||
const isMounted = useMounted()
|
||||
|
||||
const url = React.useMemo(() => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params.preset) {
|
||||
searchParams.set("preset", params.preset)
|
||||
}
|
||||
|
||||
searchParams.set("base", params.base)
|
||||
|
||||
return `${process.env.NEXT_PUBLIC_APP_URL}/init/v0?${searchParams.toString()}`
|
||||
}, [params.preset, params.base])
|
||||
|
||||
const title = React.useMemo(() => {
|
||||
return params.base && params.style
|
||||
? `New ${params.base}-${params.style} project`
|
||||
: "New Project"
|
||||
}, [params.base, params.style])
|
||||
|
||||
if (!isMounted) {
|
||||
return <Skeleton className="h-8 w-24 rounded-lg" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
nativeButton={false}
|
||||
role="link"
|
||||
variant={isMobile ? "default" : "outline"}
|
||||
className={cn("h-[31px] gap-1 rounded-lg", className)}
|
||||
render={
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_V0_URL}/chat/api/open?url=${url}&title=${title}`}
|
||||
target="_blank"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>Open in</span>
|
||||
<Icons.v0 className="size-5" data-icon="inline-end" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
68
apps/v4/app/(app)/(create)/components/welcome-dialog.tsx
Normal file
68
apps/v4/app/(app)/(create)/components/welcome-dialog.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { Icons } from "@/components/icons"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/styles/base-nova/ui/dialog"
|
||||
|
||||
const STORAGE_KEY = "shadcn-create-welcome-dialog"
|
||||
|
||||
export function WelcomeDialog() {
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const dismissed = localStorage.getItem(STORAGE_KEY)
|
||||
if (!dismissed) {
|
||||
setIsOpen(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Stable callback — avoids re-creation on every render. (rerender-functional-setstate)
|
||||
const handleOpenChange = React.useCallback((open: boolean) => {
|
||||
setIsOpen(open)
|
||||
if (!open) {
|
||||
localStorage.setItem(STORAGE_KEY, "true")
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="dialog-ring max-w-92 min-w-0 gap-0 overflow-hidden rounded-xl p-0 sm:max-w-sm dark:bg-neutral-900"
|
||||
>
|
||||
<div className="flex aspect-[2/1.2] w-full items-center justify-center rounded-t-xl bg-neutral-950 text-center text-neutral-100 sm:aspect-2/1">
|
||||
<div className="font-mono text-2xl font-bold">
|
||||
<Icons.logo className="size-12" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogHeader className="gap-1 p-4">
|
||||
<DialogTitle className="text-left text-base">
|
||||
Build your own shadcn/ui
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-left leading-relaxed text-foreground">
|
||||
Customize everything from the ground up. Pick your component
|
||||
library, font, color scheme, and more.
|
||||
</DialogDescription>
|
||||
<DialogDescription className="mt-2 text-left leading-relaxed font-medium text-foreground">
|
||||
Available for all major React frameworks.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="m-0">
|
||||
<DialogClose render={<Button className="w-full" />}>
|
||||
Get Started
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
22
apps/v4/app/(app)/(create)/create/layout.tsx
Normal file
22
apps/v4/app/(app)/(create)/create/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { CreateSkeleton } from "@/app/(app)/(create)/components/create-skeleton"
|
||||
import { HistoryProvider } from "@/app/(app)/(create)/hooks/use-history"
|
||||
import { LocksProvider } from "@/app/(app)/(create)/hooks/use-locks"
|
||||
|
||||
export default function CreateLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<LocksProvider>
|
||||
{/* HistoryProvider reads useSearchParams(), which opts this subtree out
|
||||
of prerendering up to this boundary. The fallback is what paints on
|
||||
first load, so it must be the page placeholder, not empty. */}
|
||||
<Suspense fallback={<CreateSkeleton />}>
|
||||
<HistoryProvider>{children}</HistoryProvider>
|
||||
</Suspense>
|
||||
</LocksProvider>
|
||||
)
|
||||
}
|
||||
5
apps/v4/app/(app)/(create)/create/loading.tsx
Normal file
5
apps/v4/app/(app)/(create)/create/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CreateSkeleton } from "@/app/(app)/(create)/components/create-skeleton"
|
||||
|
||||
export default function CreateLoading() {
|
||||
return <CreateSkeleton />
|
||||
}
|
||||
74
apps/v4/app/(app)/(create)/create/page.tsx
Normal file
74
apps/v4/app/(app)/(create)/create/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Suspense } from "react"
|
||||
import { type Metadata } from "next"
|
||||
import dynamic from "next/dynamic"
|
||||
|
||||
import { siteConfig } from "@/lib/config"
|
||||
import { absoluteUrl } from "@/lib/utils"
|
||||
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
|
||||
import { Customizer } from "@/app/(app)/(create)/components/customizer"
|
||||
import { PresetHandler } from "@/app/(app)/(create)/components/preset-handler"
|
||||
import { Preview } from "@/app/(app)/(create)/components/preview"
|
||||
import { getAllItems } from "@/app/(app)/(create)/lib/api"
|
||||
|
||||
// Only shown on first visit (checks localStorage).
|
||||
const WelcomeDialog = dynamic(() =>
|
||||
import("@/app/(app)/(create)/components/welcome-dialog").then(
|
||||
(m) => m.WelcomeDialog
|
||||
)
|
||||
)
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "New Project",
|
||||
description:
|
||||
"Customize everything. Pick your component library, icons, base color, theme, fonts and create your own version of shadcn/ui.",
|
||||
openGraph: {
|
||||
title: "New Project",
|
||||
description:
|
||||
"Customize everything. Pick your component library, icons, base color, theme, fonts and create your own version of shadcn/ui.",
|
||||
type: "website",
|
||||
url: absoluteUrl("/create"),
|
||||
images: [
|
||||
{
|
||||
url: siteConfig.ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteConfig.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "New Project",
|
||||
description:
|
||||
"Customize everything. Pick your component library, icons, base color, theme, fonts and create your own version of shadcn/ui.",
|
||||
images: [siteConfig.ogImage],
|
||||
creator: "@shadcn",
|
||||
},
|
||||
}
|
||||
|
||||
export default function CreatePage() {
|
||||
return (
|
||||
<div className="relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden section-soft [--customizer-width:--spacing(48)] [--gap:--spacing(4)] md:[--gap:--spacing(6)] 2xl:[--customizer-width:--spacing(56)]">
|
||||
<div
|
||||
data-slot="designer"
|
||||
className="flex min-h-0 flex-1 flex-col gap-(--gap) p-(--gap) pt-[calc(var(--gap)*0.25)] md:flex-row-reverse"
|
||||
>
|
||||
<Preview />
|
||||
<Suspense
|
||||
fallback={
|
||||
<Skeleton className="isolate min-h-[151px] w-full self-start rounded-2xl md:h-full md:max-h-full md:min-h-0 md:w-(--customizer-width)" />
|
||||
}
|
||||
>
|
||||
<CustomizerLoader />
|
||||
</Suspense>
|
||||
</div>
|
||||
<PresetHandler />
|
||||
<WelcomeDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function CustomizerLoader() {
|
||||
const itemsByBase = await getAllItems()
|
||||
return <Customizer itemsByBase={itemsByBase} />
|
||||
}
|
||||
142
apps/v4/app/(app)/(create)/hooks/use-action-menu.ts
Normal file
142
apps/v4/app/(app)/(create)/hooks/use-action-menu.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type RegistryItem } from "shadcn/schema"
|
||||
import useSWR from "swr"
|
||||
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
import { groupItemsByType } from "@/app/(app)/(create)/lib/utils"
|
||||
|
||||
const ACTION_MENU_OPEN_KEY = "create:action-menu-open"
|
||||
|
||||
type ActionMenuItem = {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
registryName: string
|
||||
}
|
||||
|
||||
type ActionMenuGroup = {
|
||||
type: string
|
||||
title: string
|
||||
items: ActionMenuItem[]
|
||||
}
|
||||
|
||||
type ActionMenuSourceItem = Pick<RegistryItem, "name" | "title" | "type">
|
||||
|
||||
const SEARCH_KEYWORDS: Record<string, string> = {
|
||||
"registry:block": "block blocks component components",
|
||||
"registry:item": "item items component components",
|
||||
}
|
||||
|
||||
function sortRegistryGroups(groups: ReturnType<typeof groupItemsByType>) {
|
||||
return [...groups].sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return a.title.localeCompare(b.title)
|
||||
}
|
||||
if (a.type === "registry:block") {
|
||||
return -1
|
||||
}
|
||||
if (b.type === "registry:block") {
|
||||
return 1
|
||||
}
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
}
|
||||
|
||||
export function useActionMenu(
|
||||
itemsByBase: Record<string, ActionMenuSourceItem[]>
|
||||
) {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
|
||||
ACTION_MENU_OPEN_KEY,
|
||||
{
|
||||
fallbackData: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
)
|
||||
|
||||
const groups = React.useMemo<ActionMenuGroup[]>(() => {
|
||||
const currentBaseItems = itemsByBase?.[params.base] ?? []
|
||||
const sortedRegistryGroups = sortRegistryGroups(
|
||||
groupItemsByType(currentBaseItems)
|
||||
)
|
||||
|
||||
return sortedRegistryGroups.map((group) => ({
|
||||
type: group.type,
|
||||
title: group.title,
|
||||
items: group.items.map((item) => ({
|
||||
id: `${group.type}:${item.name}`,
|
||||
type: group.type,
|
||||
label: item.title ?? item.name,
|
||||
registryName: item.name,
|
||||
})),
|
||||
}))
|
||||
}, [itemsByBase, params.base])
|
||||
|
||||
const activeRegistryName = params.item
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(registryName: string) => {
|
||||
setParams({ item: registryName })
|
||||
void setOpenData(false, { revalidate: false })
|
||||
},
|
||||
[setOpenData, setParams]
|
||||
)
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
void setOpenData(nextOpen, { revalidate: false })
|
||||
},
|
||||
[setOpenData]
|
||||
)
|
||||
|
||||
const getCommandValue = React.useCallback((item: ActionMenuItem) => {
|
||||
const keywords = SEARCH_KEYWORDS[item.type] ?? item.type.replace(":", " ")
|
||||
return `${item.label ?? ""} ${keywords}`.trim()
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "p" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
void setOpenData((currentOpen = false) => !currentOpen, {
|
||||
revalidate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", down)
|
||||
}
|
||||
}, [setOpenData])
|
||||
|
||||
return {
|
||||
activeRegistryName,
|
||||
getCommandValue,
|
||||
groups,
|
||||
handleSelect,
|
||||
open,
|
||||
setOpen: handleOpenChange,
|
||||
}
|
||||
}
|
||||
|
||||
export function useActionMenuTrigger() {
|
||||
const { mutate: setOpenData } = useSWR<boolean>(ACTION_MENU_OPEN_KEY, {
|
||||
fallbackData: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const openActionMenu = React.useCallback(() => {
|
||||
void setOpenData(true, { revalidate: false })
|
||||
}, [setOpenData])
|
||||
|
||||
return {
|
||||
openActionMenu,
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
185
apps/v4/app/(app)/(create)/hooks/use-history.tsx
Normal file
185
apps/v4/app/(app)/(create)/hooks/use-history.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Suspense } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
|
||||
import {
|
||||
useDesignSystemSearchParams,
|
||||
type DesignSystemSearchParams,
|
||||
} from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
type HistoryContextValue = {
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
goBack: () => void
|
||||
goForward: () => void
|
||||
}
|
||||
|
||||
const HistoryContext = React.createContext<HistoryContextValue | null>(null)
|
||||
|
||||
// Reads useSearchParams() in its own Suspense boundary so the
|
||||
// provider never blanks out children while search params resolve.
|
||||
// useSearchParams reflects the *settled* preset, which coalesces the
|
||||
// transient values nuqs emits mid-update — reading nuqs state directly
|
||||
// here would record those transients as phantom history entries.
|
||||
function PresetSync({
|
||||
onPresetChange,
|
||||
}: {
|
||||
onPresetChange: (preset: string) => void
|
||||
}) {
|
||||
const searchParams = useSearchParams()
|
||||
const preset = searchParams.get("preset") ?? ""
|
||||
|
||||
React.useEffect(() => {
|
||||
onPresetChange(preset)
|
||||
}, [preset, onPresetChange])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
||||
// Write through the shared search-params hook (shallow) instead of
|
||||
// router.replace. router.replace triggers a full server navigation that
|
||||
// resets the preset on prod — the same failure the customizer avoids by
|
||||
// going shallow (#11060).
|
||||
const [, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const [preset, setPreset] = React.useState("")
|
||||
|
||||
const entriesRef = React.useRef<string[]>([preset])
|
||||
const indexRef = React.useRef(0)
|
||||
const maxIndexRef = React.useRef(0)
|
||||
const isNavigatingRef = React.useRef(false)
|
||||
|
||||
const [index, setIndex] = React.useState(0)
|
||||
const [maxIndex, setMaxIndex] = React.useState(0)
|
||||
|
||||
const onPresetChange = React.useCallback((nextPreset: string) => {
|
||||
setPreset(nextPreset)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isNavigatingRef.current) {
|
||||
isNavigatingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (preset === entriesRef.current[indexRef.current]) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextEntries = entriesRef.current.slice(0, indexRef.current + 1)
|
||||
nextEntries.push(preset)
|
||||
entriesRef.current = nextEntries
|
||||
|
||||
const nextIndex = nextEntries.length - 1
|
||||
indexRef.current = nextIndex
|
||||
maxIndexRef.current = nextIndex
|
||||
setIndex(nextIndex)
|
||||
setMaxIndex(nextIndex)
|
||||
}, [preset])
|
||||
|
||||
const canGoBack = index > 0
|
||||
const canGoForward = index < maxIndex
|
||||
|
||||
const goBack = React.useCallback(() => {
|
||||
if (indexRef.current <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
isNavigatingRef.current = true
|
||||
const nextIndex = indexRef.current - 1
|
||||
indexRef.current = nextIndex
|
||||
setIndex(nextIndex)
|
||||
|
||||
// The first history entry is "" (no preset in the URL); null clears the
|
||||
// param to restore that state. nuqs accepts null to clear a key, which the
|
||||
// wrapper's input type does not model.
|
||||
setParams(
|
||||
{
|
||||
preset: entriesRef.current[nextIndex] || null,
|
||||
} as Partial<DesignSystemSearchParams>,
|
||||
{ history: "replace" }
|
||||
)
|
||||
}, [setParams])
|
||||
|
||||
const goForward = React.useCallback(() => {
|
||||
if (indexRef.current >= maxIndexRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
isNavigatingRef.current = true
|
||||
const nextIndex = indexRef.current + 1
|
||||
indexRef.current = nextIndex
|
||||
setIndex(nextIndex)
|
||||
|
||||
// The first history entry is "" (no preset in the URL); null clears the
|
||||
// param to restore that state. nuqs accepts null to clear a key, which the
|
||||
// wrapper's input type does not model.
|
||||
setParams(
|
||||
{
|
||||
preset: entriesRef.current[nextIndex] || null,
|
||||
} as Partial<DesignSystemSearchParams>,
|
||||
{ history: "replace" }
|
||||
)
|
||||
}, [setParams])
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (!e.metaKey && !e.ctrlKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase()
|
||||
|
||||
if ((key === "z" && e.shiftKey) || (key === "y" && e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
goForward()
|
||||
return
|
||||
}
|
||||
|
||||
if (key === "z") {
|
||||
e.preventDefault()
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", down)
|
||||
}
|
||||
}, [goBack, goForward])
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ canGoBack, canGoForward, goBack, goForward }),
|
||||
[canGoBack, canGoForward, goBack, goForward]
|
||||
)
|
||||
|
||||
return (
|
||||
<HistoryContext value={value}>
|
||||
<Suspense>
|
||||
<PresetSync onPresetChange={onPresetChange} />
|
||||
</Suspense>
|
||||
{children}
|
||||
</HistoryContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function useHistory() {
|
||||
const context = React.useContext(HistoryContext)
|
||||
if (!context) {
|
||||
throw new Error("useHistory must be used within HistoryProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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: "design-system-params"
|
||||
@@ -23,6 +23,12 @@ export function useIframeMessageListener<
|
||||
messageType: MessageType,
|
||||
onMessage: (data: Extract<Message, { type: MessageType }>["data"]) => void
|
||||
) {
|
||||
const onMessageRef = React.useRef(onMessage)
|
||||
|
||||
React.useEffect(() => {
|
||||
onMessageRef.current = onMessage
|
||||
}, [onMessage])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isInIframe()) {
|
||||
return
|
||||
@@ -30,7 +36,7 @@ export function useIframeMessageListener<
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data.type === messageType) {
|
||||
onMessage(event.data.data)
|
||||
onMessageRef.current(event.data.data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +44,7 @@ export function useIframeMessageListener<
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}, [messageType, onMessage])
|
||||
}, [messageType])
|
||||
}
|
||||
|
||||
export function sendToIframe<
|
||||
@@ -6,8 +6,10 @@ export type LockableParam =
|
||||
| "style"
|
||||
| "baseColor"
|
||||
| "theme"
|
||||
| "chartColor"
|
||||
| "iconLibrary"
|
||||
| "font"
|
||||
| "fontHeading"
|
||||
| "menuAccent"
|
||||
| "menuColor"
|
||||
| "radius"
|
||||
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,
|
||||
}
|
||||
}
|
||||
249
apps/v4/app/(app)/(create)/hooks/use-random.tsx
Normal file
249
apps/v4/app/(app)/(create)/hooks/use-random.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { decodePreset } from "shadcn/preset"
|
||||
|
||||
import {
|
||||
BASE_COLORS,
|
||||
getThemesForBaseColor,
|
||||
iconLibraries,
|
||||
MENU_ACCENTS,
|
||||
MENU_COLORS,
|
||||
RADII,
|
||||
STYLES,
|
||||
type BaseColorName,
|
||||
type ChartColorName,
|
||||
type FontHeadingValue,
|
||||
type FontValue,
|
||||
type IconLibraryName,
|
||||
type MenuAccentValue,
|
||||
type MenuColorValue,
|
||||
type RadiusValue,
|
||||
type StyleName,
|
||||
type ThemeName,
|
||||
} from "@/registry/config"
|
||||
import { useLocks } from "@/app/(app)/(create)/hooks/use-locks"
|
||||
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
|
||||
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
||||
import {
|
||||
applyBias,
|
||||
RANDOMIZE_BIASES,
|
||||
type RandomizeContext,
|
||||
} from "@/app/(app)/(create)/lib/randomize-biases"
|
||||
import {
|
||||
isTranslucentMenuColor,
|
||||
useDesignSystemSearchParams,
|
||||
} from "@/app/(app)/(create)/lib/search-params"
|
||||
import { SHUFFLE_PRESETS } from "@/app/(app)/(create)/lib/shuffle-presets"
|
||||
|
||||
function randomItem<T>(array: readonly T[]): T {
|
||||
return array[Math.floor(Math.random() * array.length)]
|
||||
}
|
||||
|
||||
export function useRandom() {
|
||||
const { locks } = useLocks()
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const paramsRef = React.useRef(params)
|
||||
React.useEffect(() => {
|
||||
paramsRef.current = params
|
||||
}, [params])
|
||||
|
||||
// True-random implementation. Kept, but shuffle is now wired to the
|
||||
// curated preset list below.
|
||||
const randomizeTrueRandom = React.useCallback(() => {
|
||||
const selectedStyle = locks.has("style")
|
||||
? paramsRef.current.style
|
||||
: randomItem(STYLES).name
|
||||
|
||||
const context: RandomizeContext = {
|
||||
style: selectedStyle,
|
||||
}
|
||||
|
||||
const availableBaseColors = applyBias(
|
||||
BASE_COLORS,
|
||||
context,
|
||||
RANDOMIZE_BIASES.baseColors
|
||||
)
|
||||
const baseColor = locks.has("baseColor")
|
||||
? paramsRef.current.baseColor
|
||||
: randomItem(availableBaseColors).name
|
||||
context.baseColor = baseColor
|
||||
|
||||
const availableThemes = getThemesForBaseColor(baseColor)
|
||||
const availableFonts = applyBias(FONTS, context, RANDOMIZE_BIASES.fonts)
|
||||
const availableRadii = applyBias(RADII, context, RANDOMIZE_BIASES.radius)
|
||||
|
||||
const selectedTheme = locks.has("theme")
|
||||
? paramsRef.current.theme
|
||||
: randomItem(availableThemes).name
|
||||
context.theme = selectedTheme
|
||||
|
||||
const availableChartColors = applyBias(
|
||||
getThemesForBaseColor(baseColor),
|
||||
context,
|
||||
RANDOMIZE_BIASES.chartColors
|
||||
)
|
||||
const selectedChartColor = locks.has("chartColor")
|
||||
? paramsRef.current.chartColor
|
||||
: randomItem(availableChartColors).name
|
||||
context.chartColor = selectedChartColor
|
||||
const selectedFont = locks.has("font")
|
||||
? paramsRef.current.font
|
||||
: randomItem(availableFonts).value
|
||||
context.font = selectedFont
|
||||
|
||||
// Pick heading font: ~70% inherit, ~30% distinct with cross-category contrast.
|
||||
let selectedFontHeading: FontHeadingValue
|
||||
if (locks.has("fontHeading")) {
|
||||
selectedFontHeading = paramsRef.current.fontHeading
|
||||
} else if (Math.random() < 0.7) {
|
||||
selectedFontHeading = "inherit"
|
||||
} else {
|
||||
const bodyType = availableFonts.find(
|
||||
(f) => f.value === selectedFont
|
||||
)?.type
|
||||
const contrastFonts = availableFonts.filter(
|
||||
(f) => f.type !== bodyType && f.value !== selectedFont
|
||||
)
|
||||
selectedFontHeading = (
|
||||
contrastFonts.length > 0
|
||||
? randomItem(contrastFonts)
|
||||
: randomItem(availableFonts)
|
||||
).value as FontHeadingValue
|
||||
}
|
||||
const selectedRadius = locks.has("radius")
|
||||
? paramsRef.current.radius
|
||||
: randomItem(availableRadii).name
|
||||
const selectedIconLibrary = locks.has("iconLibrary")
|
||||
? paramsRef.current.iconLibrary
|
||||
: randomItem(Object.values(iconLibraries)).name
|
||||
const lockedMenuAccent = locks.has("menuAccent")
|
||||
? paramsRef.current.menuAccent
|
||||
: undefined
|
||||
const availableMenuColors =
|
||||
!locks.has("menuColor") && lockedMenuAccent === "bold"
|
||||
? MENU_COLORS.filter((menuColor) => {
|
||||
return !isTranslucentMenuColor(menuColor.value)
|
||||
})
|
||||
: MENU_COLORS
|
||||
const selectedMenuColor = locks.has("menuColor")
|
||||
? paramsRef.current.menuColor
|
||||
: randomItem(availableMenuColors).value
|
||||
const selectedMenuAccent =
|
||||
locks.has("menuAccent") || isTranslucentMenuColor(selectedMenuColor)
|
||||
? paramsRef.current.menuAccent === "bold" &&
|
||||
isTranslucentMenuColor(selectedMenuColor)
|
||||
? "subtle"
|
||||
: paramsRef.current.menuAccent
|
||||
: randomItem(MENU_ACCENTS).value
|
||||
|
||||
context.radius = selectedRadius
|
||||
|
||||
const nextParams = {
|
||||
style: selectedStyle,
|
||||
baseColor,
|
||||
theme: selectedTheme,
|
||||
chartColor: selectedChartColor,
|
||||
iconLibrary: selectedIconLibrary,
|
||||
font: selectedFont,
|
||||
fontHeading: selectedFontHeading,
|
||||
menuAccent: selectedMenuAccent,
|
||||
menuColor: selectedMenuColor,
|
||||
radius: selectedRadius,
|
||||
}
|
||||
|
||||
// Keep the ref in sync so rapid repeats use the latest randomized state
|
||||
// even before the URL state finishes committing.
|
||||
paramsRef.current = {
|
||||
...paramsRef.current,
|
||||
...nextParams,
|
||||
}
|
||||
|
||||
setParams(nextParams)
|
||||
}, [setParams, locks])
|
||||
|
||||
// Picks a random preset from the curated list instead of true random.
|
||||
// Locked params are preserved over the decoded preset values.
|
||||
const randomize = React.useCallback(() => {
|
||||
// Avoid re-picking the preset that is currently applied.
|
||||
const currentCode = getPresetCode(paramsRef.current)
|
||||
const availableCodes = SHUFFLE_PRESETS.filter(
|
||||
(code) => code !== currentCode
|
||||
)
|
||||
const decoded = decodePreset(
|
||||
randomItem(availableCodes.length > 0 ? availableCodes : SHUFFLE_PRESETS)
|
||||
)
|
||||
|
||||
if (!decoded) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = paramsRef.current
|
||||
const nextParams = {
|
||||
style: locks.has("style") ? current.style : (decoded.style as StyleName),
|
||||
baseColor: locks.has("baseColor")
|
||||
? current.baseColor
|
||||
: (decoded.baseColor as BaseColorName),
|
||||
theme: locks.has("theme") ? current.theme : (decoded.theme as ThemeName),
|
||||
chartColor: locks.has("chartColor")
|
||||
? current.chartColor
|
||||
: ((decoded.chartColor ?? decoded.theme) as ChartColorName),
|
||||
iconLibrary: locks.has("iconLibrary")
|
||||
? current.iconLibrary
|
||||
: (decoded.iconLibrary as IconLibraryName),
|
||||
font: locks.has("font") ? current.font : (decoded.font as FontValue),
|
||||
fontHeading: locks.has("fontHeading")
|
||||
? current.fontHeading
|
||||
: (decoded.fontHeading as FontHeadingValue),
|
||||
menuAccent: locks.has("menuAccent")
|
||||
? current.menuAccent
|
||||
: (decoded.menuAccent as MenuAccentValue),
|
||||
menuColor: locks.has("menuColor")
|
||||
? current.menuColor
|
||||
: (decoded.menuColor as MenuColorValue),
|
||||
radius: locks.has("radius")
|
||||
? current.radius
|
||||
: (decoded.radius as RadiusValue),
|
||||
}
|
||||
|
||||
// Keep the ref in sync so rapid repeats use the latest randomized state
|
||||
// even before the URL state finishes committing.
|
||||
paramsRef.current = {
|
||||
...paramsRef.current,
|
||||
...nextParams,
|
||||
}
|
||||
|
||||
setParams(nextParams)
|
||||
}, [setParams, locks])
|
||||
|
||||
const randomizeRef = React.useRef(randomize)
|
||||
React.useEffect(() => {
|
||||
randomizeRef.current = randomize
|
||||
}, [randomize])
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "r" && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
randomizeRef.current()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", down)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { randomize, randomizeTrueRandom }
|
||||
}
|
||||
103
apps/v4/app/(app)/(create)/hooks/use-reset.tsx
Normal file
103
apps/v4/app/(app)/(create)/hooks/use-reset.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
|
||||
import { DEFAULT_CONFIG, PRESETS } from "@/registry/config"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params"
|
||||
|
||||
const RESET_DIALOG_KEY = "create:reset-dialog-open"
|
||||
export const RESET_FORWARD_TYPE = "reset-forward"
|
||||
|
||||
export function useReset() {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
const { data: showResetDialog = false, mutate: setShowResetDialogData } =
|
||||
useSWR<boolean>(RESET_DIALOG_KEY, {
|
||||
fallbackData: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
const preset =
|
||||
PRESETS.find(
|
||||
(preset) => preset.base === params.base && preset.style === params.style
|
||||
) ?? DEFAULT_CONFIG
|
||||
|
||||
setParams({
|
||||
base: params.base,
|
||||
style: params.style,
|
||||
baseColor: preset.baseColor,
|
||||
theme: preset.theme,
|
||||
chartColor: preset.chartColor,
|
||||
iconLibrary: preset.iconLibrary,
|
||||
font: preset.font,
|
||||
fontHeading: preset.fontHeading,
|
||||
menuAccent: preset.menuAccent,
|
||||
menuColor: preset.menuColor,
|
||||
radius: preset.radius,
|
||||
template: DEFAULT_CONFIG.template,
|
||||
item: params.item,
|
||||
})
|
||||
}, [setParams, params.base, params.style, params.item])
|
||||
|
||||
const handleShowResetDialogChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
void setShowResetDialogData(open, { revalidate: false })
|
||||
},
|
||||
[setShowResetDialogData]
|
||||
)
|
||||
|
||||
const confirmReset = React.useCallback(() => {
|
||||
reset()
|
||||
void setShowResetDialogData(false, { revalidate: false })
|
||||
}, [reset, setShowResetDialogData])
|
||||
|
||||
const showResetDialogRef = React.useRef(showResetDialog)
|
||||
React.useEffect(() => {
|
||||
showResetDialogRef.current = showResetDialog
|
||||
}, [showResetDialog])
|
||||
|
||||
const confirmResetRef = React.useRef(confirmReset)
|
||||
React.useEffect(() => {
|
||||
confirmResetRef.current = confirmReset
|
||||
}, [confirmReset])
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "R" && e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
// If the dialog is already open, confirm the reset.
|
||||
if (showResetDialogRef.current) {
|
||||
confirmResetRef.current()
|
||||
return
|
||||
}
|
||||
|
||||
handleShowResetDialogChange(true)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", down)
|
||||
}
|
||||
}, [handleShowResetDialogChange])
|
||||
|
||||
return {
|
||||
reset,
|
||||
showResetDialog,
|
||||
setShowResetDialog: handleShowResetDialogChange,
|
||||
confirmReset,
|
||||
}
|
||||
}
|
||||
48
apps/v4/app/(app)/(create)/hooks/use-theme-toggle.tsx
Normal file
48
apps/v4/app/(app)/(create)/hooks/use-theme-toggle.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { useMetaColor } from "@/hooks/use-meta-color"
|
||||
|
||||
export function useThemeToggle() {
|
||||
const { setTheme, resolvedTheme } = useTheme()
|
||||
const { setMetaColor, metaColor } = useMetaColor()
|
||||
|
||||
React.useEffect(() => {
|
||||
setMetaColor(metaColor)
|
||||
}, [metaColor, setMetaColor])
|
||||
|
||||
const toggleTheme = React.useCallback(() => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}, [resolvedTheme, setTheme])
|
||||
|
||||
// Listen for the D key to toggle theme.
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.key === "d" || e.key === "D") &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
toggleTheme()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
return () => document.removeEventListener("keydown", down)
|
||||
}, [toggleTheme])
|
||||
|
||||
return { toggleTheme }
|
||||
}
|
||||
29
apps/v4/app/(app)/(create)/init/md/route.ts
Normal file
29
apps/v4/app/(app)/(create)/init/md/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type NextRequest } from "next/server"
|
||||
import { track } from "@vercel/analytics/server"
|
||||
|
||||
import { buildInstructions } from "@/app/(app)/(create)/lib/build-instructions"
|
||||
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const result = parseDesignSystemConfig(searchParams)
|
||||
|
||||
if (!result.success) {
|
||||
return new Response(result.error, { status: 400 })
|
||||
}
|
||||
|
||||
track("create_app_manual", result.data)
|
||||
|
||||
const markdown = buildInstructions(result.data)
|
||||
|
||||
return new Response(markdown, {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
})
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
error instanceof Error ? error.message : "An unknown error occurred",
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
74
apps/v4/app/(app)/(create)/init/route.test.ts
Normal file
74
apps/v4/app/(app)/(create)/init/route.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
buildRegistryBase,
|
||||
DEFAULT_CONFIG,
|
||||
POINTER_CURSOR_SELECTOR,
|
||||
} from "@/registry/config"
|
||||
|
||||
import { GET } from "./route"
|
||||
|
||||
function createRequest(search = "") {
|
||||
const searchParams = new URLSearchParams(
|
||||
Object.entries(DEFAULT_CONFIG).map(([key, value]) => [key, String(value)])
|
||||
)
|
||||
const url = new URL(`http://localhost:4000/init${search}`)
|
||||
|
||||
for (const [key, value] of url.searchParams) {
|
||||
searchParams.set(key, value)
|
||||
}
|
||||
|
||||
return {
|
||||
nextUrl: new URL(`http://localhost:4000/init?${searchParams}`),
|
||||
} as Parameters<typeof GET>[0]
|
||||
}
|
||||
|
||||
describe("GET /init", () => {
|
||||
it("returns the full registry base when only is omitted", async () => {
|
||||
const response = await GET(createRequest())
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(json).toEqual(buildRegistryBase(DEFAULT_CONFIG))
|
||||
expect(json.css["@layer base"][POINTER_CURSOR_SELECTOR]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns pointer cursor css when pointer is enabled", async () => {
|
||||
const response = await GET(createRequest("?pointer=true"))
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(json.css["@layer base"][POINTER_CURSOR_SELECTOR]).toEqual({
|
||||
cursor: "pointer",
|
||||
})
|
||||
})
|
||||
|
||||
it("returns a sparse registry base when only is provided", async () => {
|
||||
const response = await GET(createRequest("?only=theme"))
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(json.type).toBe("registry:base")
|
||||
expect(json.config).toEqual({
|
||||
menuColor: "default",
|
||||
menuAccent: "subtle",
|
||||
tailwind: {
|
||||
baseColor: "neutral",
|
||||
},
|
||||
})
|
||||
expect(json.cssVars.light).toBeDefined()
|
||||
expect(json.cssVars.light.radius).toBe("0.625rem")
|
||||
expect(json.dependencies).toBeUndefined()
|
||||
expect(json.registryDependencies).toBeUndefined()
|
||||
})
|
||||
|
||||
it("rejects unsupported only values", async () => {
|
||||
const response = await GET(createRequest("?only=icon"))
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(json.error).toBe(
|
||||
"Invalid only value. Use one or more of: theme, font"
|
||||
)
|
||||
})
|
||||
})
|
||||
66
apps/v4/app/(app)/(create)/init/route.ts
Normal file
66
apps/v4/app/(app)/(create)/init/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse, type NextRequest } from "next/server"
|
||||
import { track } from "@vercel/analytics/server"
|
||||
import { isPresetCode } from "shadcn/preset"
|
||||
import { registryItemSchema } from "shadcn/schema"
|
||||
|
||||
import {
|
||||
buildPartialRegistryBase,
|
||||
buildRegistryBase,
|
||||
parseRegistryBaseParts,
|
||||
} from "@/registry/config"
|
||||
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
|
||||
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const result = parseDesignSystemConfig(searchParams)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const onlyResult = parseRegistryBaseParts(searchParams.get("only"))
|
||||
if (!onlyResult.success) {
|
||||
return NextResponse.json({ error: onlyResult.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const rawPreset = searchParams.get("preset")
|
||||
const presetCode =
|
||||
rawPreset && isPresetCode(rawPreset)
|
||||
? rawPreset
|
||||
: getPresetCode(result.data)
|
||||
|
||||
const registryBase = onlyResult.parts
|
||||
? buildPartialRegistryBase(result.data, onlyResult.parts)
|
||||
: buildRegistryBase(result.data)
|
||||
const parseResult = registryItemSchema.safeParse(registryBase)
|
||||
|
||||
if (!parseResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid registry base item",
|
||||
details: parseResult.error.format(),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
if (searchParams.get("track") === "1") {
|
||||
track("create_app", {
|
||||
...result.data,
|
||||
preset: presetCode,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(parseResult.data)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error ? error.message : "An unknown error occurred",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
44
apps/v4/app/(app)/(create)/init/v0/route.ts
Normal file
44
apps/v4/app/(app)/(create)/init/v0/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { after, NextResponse, type NextRequest } from "next/server"
|
||||
import { track } from "@vercel/analytics/server"
|
||||
import { isPresetCode } from "shadcn/preset"
|
||||
|
||||
import { parseDesignSystemConfig } from "@/app/(app)/(create)/lib/parse-config"
|
||||
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
||||
import { buildV0Payload } from "@/app/(app)/(create)/lib/v0"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const result = parseDesignSystemConfig(searchParams)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const rawPreset = searchParams.get("preset")
|
||||
const presetCode =
|
||||
rawPreset && isPresetCode(rawPreset)
|
||||
? rawPreset
|
||||
: getPresetCode(result.data)
|
||||
|
||||
// Defer analytics to after response is sent.
|
||||
after(() => {
|
||||
track("create_open_in_v0", {
|
||||
...result.data,
|
||||
preset: presetCode,
|
||||
})
|
||||
})
|
||||
|
||||
const payload = await buildV0Payload(result.data)
|
||||
|
||||
return NextResponse.json(payload)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error ? error.message : "An unknown error occurred",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
73
apps/v4/app/(app)/(create)/lib/api.ts
Normal file
73
apps/v4/app/(app)/(create)/lib/api.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import "server-only"
|
||||
|
||||
import { registryItemSchema } from "shadcn/schema"
|
||||
|
||||
import { BASES, getThemesForBaseColor, type BaseName } from "@/registry/config"
|
||||
import {
|
||||
ALLOWED_ITEM_TYPES,
|
||||
EXCLUDED_ITEMS,
|
||||
} from "@/app/(app)/(create)/lib/constants"
|
||||
|
||||
export async function getItemsForBase(base: BaseName) {
|
||||
const { Index } = await import("@/registry/bases/__index__")
|
||||
const index = Index[base]
|
||||
|
||||
if (!index) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.values(index).filter(
|
||||
(item) =>
|
||||
ALLOWED_ITEM_TYPES.includes(item.type) &&
|
||||
!EXCLUDED_ITEMS.includes(item.name)
|
||||
)
|
||||
}
|
||||
|
||||
export async function getBaseItem(name: string, base: BaseName) {
|
||||
const { Index } = await import("@/registry/bases/__index__")
|
||||
const index = Index[base]
|
||||
|
||||
if (!index?.[name] || EXCLUDED_ITEMS.includes(name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return registryItemSchema.parse(index[name])
|
||||
}
|
||||
|
||||
export async function getBaseComponent(name: string, base: BaseName) {
|
||||
const { Index } = await import("@/registry/bases/__index__")
|
||||
const index = Index[base]
|
||||
|
||||
if (!index?.[name] || EXCLUDED_ITEMS.includes(name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { Components } = await import("@/registry/bases/__components__")
|
||||
return Components[base]?.[name] ?? null
|
||||
}
|
||||
|
||||
export async function getAllItems() {
|
||||
const entries = await Promise.all(
|
||||
BASES.map(async (base) => {
|
||||
const items = await getItemsForBase(base.name as BaseName)
|
||||
const filtered: Pick<
|
||||
NonNullable<(typeof items)[number]>,
|
||||
"name" | "title" | "type"
|
||||
>[] = []
|
||||
for (const item of items) {
|
||||
if (item !== null && !/\d+$/.test(item.name)) {
|
||||
filtered.push({
|
||||
name: item.name,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
})
|
||||
}
|
||||
}
|
||||
return [base.name, filtered] as const
|
||||
})
|
||||
)
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
// Re-export for server-side use.
|
||||
export { getThemesForBaseColor }
|
||||
297
apps/v4/app/(app)/(create)/lib/build-instructions.ts
Normal file
297
apps/v4/app/(app)/(create)/lib/build-instructions.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import dedent from "dedent"
|
||||
|
||||
import { UI_COMPONENTS } from "@/lib/components"
|
||||
import {
|
||||
buildRegistryBase,
|
||||
getBodyFont,
|
||||
getHeadingFont,
|
||||
getInheritedHeadingFontValue,
|
||||
type DesignSystemConfig,
|
||||
} from "@/registry/config"
|
||||
|
||||
// Builds step-by-step markdown instructions for manually setting up a project.
|
||||
export function buildInstructions(config: DesignSystemConfig) {
|
||||
const registryBase = buildRegistryBase(config)
|
||||
const normalizedFontHeading =
|
||||
config.fontHeading === config.font ? "inherit" : config.fontHeading
|
||||
|
||||
const dependencies = [
|
||||
...(registryBase.dependencies ?? []),
|
||||
"clsx",
|
||||
"tailwind-merge",
|
||||
]
|
||||
|
||||
const lightVars = Object.entries(registryBase.cssVars?.light ?? {})
|
||||
.map(([key, value]) => ` --${key}: ${value};`)
|
||||
.join("\n")
|
||||
|
||||
const darkVars = Object.entries(registryBase.cssVars?.dark ?? {})
|
||||
.map(([key, value]) => ` --${key}: ${value};`)
|
||||
.join("\n")
|
||||
|
||||
const font = getBodyFont(config.font)
|
||||
const headingFont =
|
||||
normalizedFontHeading === "inherit"
|
||||
? undefined
|
||||
: getHeadingFont(normalizedFontHeading)
|
||||
|
||||
const sections = [
|
||||
buildDependenciesSection(dependencies),
|
||||
buildUtilsSection(),
|
||||
buildCssSection(
|
||||
lightVars,
|
||||
darkVars,
|
||||
normalizedFontHeading === "inherit"
|
||||
? getInheritedHeadingFontValue(config.font)
|
||||
: "var(--font-heading)"
|
||||
),
|
||||
buildFontSection(font, headingFont),
|
||||
buildComponentsJsonSection(config),
|
||||
buildAvailableComponentsSection(config),
|
||||
config.rtl ? buildRtlSection(config) : null,
|
||||
]
|
||||
|
||||
return sections.filter(Boolean).join("\n\n---\n\n")
|
||||
}
|
||||
|
||||
function buildDependenciesSection(dependencies: string[]) {
|
||||
const list = dependencies.map((dep) => `- ${dep}`).join("\n")
|
||||
|
||||
return dedent`
|
||||
## Step 1: Dependencies
|
||||
|
||||
The following dependencies are required:
|
||||
|
||||
${list}
|
||||
`
|
||||
}
|
||||
|
||||
function buildUtilsSection() {
|
||||
return dedent`
|
||||
## Step 2: Create \`lib/utils.ts\`
|
||||
|
||||
\`\`\`ts
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
\`\`\`
|
||||
`
|
||||
}
|
||||
|
||||
function buildCssSection(
|
||||
lightVars: string,
|
||||
darkVars: string,
|
||||
fontHeadingValue: string
|
||||
) {
|
||||
return dedent`
|
||||
## Step 3: Set up CSS
|
||||
|
||||
Add the following to your global CSS file (e.g. \`app/globals.css\`):
|
||||
|
||||
\`\`\`css
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-sans);
|
||||
--font-heading: ${fontHeadingValue};
|
||||
--font-mono: var(--font-mono);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
${lightVars}
|
||||
}
|
||||
|
||||
.dark {
|
||||
${darkVars}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
`
|
||||
}
|
||||
|
||||
function buildFontSection(
|
||||
font: ReturnType<typeof getBodyFont>,
|
||||
headingFont: ReturnType<typeof getHeadingFont>
|
||||
) {
|
||||
if (!font) {
|
||||
return null
|
||||
}
|
||||
|
||||
const googleFontsUrl = `https://fonts.google.com/specimen/${font.font.import.replace(/_/g, "+")}`
|
||||
const headingGoogleFontsUrl = headingFont
|
||||
? `https://fonts.google.com/specimen/${headingFont.font.import.replace(/_/g, "+")}`
|
||||
: null
|
||||
const nextImports = headingFont
|
||||
? `${font.font.import}, ${headingFont.font.import}`
|
||||
: font.font.import
|
||||
const nextDeclarations = [
|
||||
`const fontSans = ${font.font.import}({`,
|
||||
` subsets: ["latin"],`,
|
||||
` variable: "${font.font.variable}",`,
|
||||
`})`,
|
||||
headingFont ? `const fontHeading = ${headingFont.font.import}({` : null,
|
||||
headingFont ? ` subsets: ["latin"],` : null,
|
||||
headingFont ? ` variable: "${headingFont.font.variable}",` : null,
|
||||
headingFont ? `})` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
const nextHtmlClassName = headingFont
|
||||
? 'className={fontSans.variable + " " + fontHeading.variable}'
|
||||
: `className={fontSans.variable}`
|
||||
const otherFrameworkCss = [
|
||||
":root {",
|
||||
` ${font.font.variable}: ${font.font.family};`,
|
||||
...(headingFont
|
||||
? [` ${headingFont.font.variable}: ${headingFont.font.family};`]
|
||||
: []),
|
||||
"}",
|
||||
].join("\n")
|
||||
const headingSection = headingFont
|
||||
? dedent`
|
||||
|
||||
This config also uses **${headingFont.title.replace(" (Heading)", "")}** for headings (\`${headingFont.font.variable}\`).
|
||||
`
|
||||
: ""
|
||||
|
||||
return dedent`
|
||||
## Step 4: Set up the font
|
||||
|
||||
This config uses **${font.title}** (\`${font.font.variable}\`).
|
||||
${headingSection}
|
||||
|
||||
### Next.js
|
||||
|
||||
\`\`\`tsx
|
||||
import { ${nextImports} } from "next/font/google"
|
||||
|
||||
${nextDeclarations}
|
||||
|
||||
// Add the font variable classes to your <html> className.
|
||||
// <html ${nextHtmlClassName}>
|
||||
\`\`\`
|
||||
|
||||
### Other frameworks
|
||||
|
||||
Add the font${headingFont ? "s" : ""} from [Google Fonts](${googleFontsUrl})${headingGoogleFontsUrl ? ` and [Google Fonts](${headingGoogleFontsUrl})` : ""} and set the CSS variable${headingFont ? "s" : ""} to the font famil${headingFont ? "ies" : "y"}:
|
||||
|
||||
\`\`\`css
|
||||
${otherFrameworkCss}
|
||||
\`\`\`
|
||||
`
|
||||
}
|
||||
|
||||
function buildComponentsJsonSection(config: DesignSystemConfig) {
|
||||
const componentsJson = {
|
||||
$schema: "https://ui.shadcn.com/schema.json",
|
||||
style: `${config.base}-${config.style}`,
|
||||
tailwind: {
|
||||
css: "app/globals.css",
|
||||
baseColor: config.baseColor,
|
||||
},
|
||||
iconLibrary: config.iconLibrary,
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
ui: "@/components/ui",
|
||||
lib: "@/lib",
|
||||
hooks: "@/hooks",
|
||||
},
|
||||
}
|
||||
|
||||
return dedent`
|
||||
## Step 5: Create \`components.json\`
|
||||
|
||||
Add a \`components.json\` file to the root of your project:
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(componentsJson, null, 2)}
|
||||
\`\`\`
|
||||
`
|
||||
}
|
||||
|
||||
function buildAvailableComponentsSection(config: DesignSystemConfig) {
|
||||
const list = UI_COMPONENTS.join(", ")
|
||||
const style = `${config.base}-${config.style}`
|
||||
|
||||
return dedent`
|
||||
## Available Components
|
||||
|
||||
${list}
|
||||
|
||||
To fetch the source for a component, use:
|
||||
\`https://ui.shadcn.com/r/styles/${style}/<component>.json\`
|
||||
|
||||
For documentation and examples, visit:
|
||||
\`https://ui.shadcn.com/docs/components/${config.base}/<component>\`
|
||||
`
|
||||
}
|
||||
|
||||
function buildRtlSection(config: DesignSystemConfig) {
|
||||
const template =
|
||||
config.template === "next-monorepo" ? "next" : (config.template ?? "next")
|
||||
|
||||
return dedent`
|
||||
## RTL Support
|
||||
|
||||
Add \`dir="rtl"\` to your root \`<html>\` element:
|
||||
|
||||
\`\`\`html
|
||||
<html dir="rtl">
|
||||
\`\`\`
|
||||
|
||||
For full RTL setup including the \`DirectionProvider\`, see the [RTL documentation](https://ui.shadcn.com/docs/rtl/${template}).
|
||||
`
|
||||
}
|
||||
3
apps/v4/app/(app)/(create)/lib/constants.ts
Normal file
3
apps/v4/app/(app)/(create)/lib/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const ALLOWED_ITEM_TYPES = ["registry:block", "registry:example"]
|
||||
|
||||
export const EXCLUDED_ITEMS = ["component-example"]
|
||||
10
apps/v4/app/(app)/(create)/lib/devtools.ts
Normal file
10
apps/v4/app/(app)/(create)/lib/devtools.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type BaseName } from "@/registry/config"
|
||||
|
||||
export function getDocsPathForItem(base: BaseName, item: string) {
|
||||
if (item.endsWith("-example")) {
|
||||
const component = item.slice(0, -"-example".length)
|
||||
return `/docs/components/${base}/${component}`
|
||||
}
|
||||
|
||||
return "/docs/components"
|
||||
}
|
||||
77
apps/v4/app/(app)/(create)/lib/fonts.ts
Normal file
77
apps/v4/app/(app)/(create)/lib/fonts.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { FONT_DEFINITIONS, type FontName } from "@/lib/font-definitions"
|
||||
|
||||
type CreateFont = {
|
||||
family: string
|
||||
import: string
|
||||
previewVariable: string
|
||||
style: {
|
||||
fontFamily: string
|
||||
}
|
||||
variable: string
|
||||
}
|
||||
|
||||
function createFontOption(name: FontName) {
|
||||
const definition = FONT_DEFINITIONS.find((font) => font.name === name)
|
||||
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown font definition: ${name}`)
|
||||
}
|
||||
|
||||
return {
|
||||
name: definition.title,
|
||||
value: definition.name,
|
||||
font: {
|
||||
family: definition.family,
|
||||
import: definition.import,
|
||||
previewVariable: definition.previewVariable,
|
||||
style: {
|
||||
fontFamily: `var(${definition.previewVariable}), ${definition.family}`,
|
||||
},
|
||||
variable: definition.registryVariable,
|
||||
} satisfies CreateFont,
|
||||
type: definition.type,
|
||||
} as const
|
||||
}
|
||||
|
||||
export const FONTS = [
|
||||
createFontOption("geist"),
|
||||
createFontOption("inter"),
|
||||
createFontOption("noto-sans"),
|
||||
createFontOption("nunito-sans"),
|
||||
createFontOption("figtree"),
|
||||
createFontOption("roboto"),
|
||||
createFontOption("raleway"),
|
||||
createFontOption("dm-sans"),
|
||||
createFontOption("public-sans"),
|
||||
createFontOption("outfit"),
|
||||
createFontOption("oxanium"),
|
||||
createFontOption("manrope"),
|
||||
createFontOption("space-grotesk"),
|
||||
createFontOption("montserrat"),
|
||||
createFontOption("ibm-plex-sans"),
|
||||
createFontOption("source-sans-3"),
|
||||
createFontOption("instrument-sans"),
|
||||
createFontOption("geist-mono"),
|
||||
createFontOption("jetbrains-mono"),
|
||||
createFontOption("noto-serif"),
|
||||
createFontOption("roboto-slab"),
|
||||
createFontOption("merriweather"),
|
||||
createFontOption("lora"),
|
||||
createFontOption("playfair-display"),
|
||||
createFontOption("eb-garamond"),
|
||||
createFontOption("instrument-serif"),
|
||||
] as const
|
||||
|
||||
export type Font = (typeof FONTS)[number]
|
||||
|
||||
export const FONT_HEADING_OPTIONS = [
|
||||
{
|
||||
name: "Inherit",
|
||||
value: "inherit",
|
||||
font: null,
|
||||
type: "default",
|
||||
},
|
||||
...FONTS,
|
||||
] as const
|
||||
|
||||
export type FontHeadingOption = (typeof FONT_HEADING_OPTIONS)[number]
|
||||
79
apps/v4/app/(app)/(create)/lib/parse-config.test.ts
Normal file
79
apps/v4/app/(app)/(create)/lib/parse-config.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { parseDesignSystemConfig } from "./parse-config"
|
||||
|
||||
describe("parseDesignSystemConfig", () => {
|
||||
it("defaults pointer to false when omitted", () => {
|
||||
const result = parseDesignSystemConfig(
|
||||
new URLSearchParams(
|
||||
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
expect(result.data.pointer).toBe(false)
|
||||
})
|
||||
|
||||
it("parses pointer=true", () => {
|
||||
const result = parseDesignSystemConfig(
|
||||
new URLSearchParams(
|
||||
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&pointer=true&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
expect(result.data.pointer).toBe(true)
|
||||
})
|
||||
|
||||
it("defaults missing chartColor from the selected theme", () => {
|
||||
const result = parseDesignSystemConfig(
|
||||
new URLSearchParams(
|
||||
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
expect(result.data.chartColor).toBe("taupe")
|
||||
})
|
||||
|
||||
it("honors explicit fontHeading and chartColor overrides when a preset is present", () => {
|
||||
const result = parseDesignSystemConfig(
|
||||
new URLSearchParams(
|
||||
"preset=a0&fontHeading=playfair-display&chartColor=emerald"
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
expect(result.data.fontHeading).toBe("playfair-display")
|
||||
expect(result.data.chartColor).toBe("emerald")
|
||||
})
|
||||
|
||||
it("keeps pointer outside preset decoding", () => {
|
||||
const result = parseDesignSystemConfig(
|
||||
new URLSearchParams("preset=a0&pointer=true")
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
expect(result.data.pointer).toBe(true)
|
||||
})
|
||||
})
|
||||
54
apps/v4/app/(app)/(create)/lib/parse-config.ts
Normal file
54
apps/v4/app/(app)/(create)/lib/parse-config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { decodePreset, isPresetCode } from "shadcn/preset"
|
||||
|
||||
import {
|
||||
designSystemConfigSchema,
|
||||
type DesignSystemConfig,
|
||||
} from "@/registry/config"
|
||||
import { resolvePresetOverrides } from "@/app/(app)/(create)/lib/preset-query"
|
||||
|
||||
// Parses design system config from URL search params.
|
||||
export function parseDesignSystemConfig(searchParams: URLSearchParams) {
|
||||
let configInput: Record<string, unknown>
|
||||
const presetParam = searchParams.get("preset")
|
||||
|
||||
if (presetParam && isPresetCode(presetParam)) {
|
||||
const decoded = decodePreset(presetParam)
|
||||
if (!decoded) {
|
||||
return { success: false as const, error: "Invalid preset code" }
|
||||
}
|
||||
const presetOverrides = resolvePresetOverrides(searchParams, decoded)
|
||||
configInput = {
|
||||
...decoded,
|
||||
...presetOverrides,
|
||||
base: searchParams.get("base") ?? "base",
|
||||
template: searchParams.get("template") ?? undefined,
|
||||
rtl: searchParams.get("rtl") === "true",
|
||||
pointer: searchParams.get("pointer") === "true",
|
||||
}
|
||||
} else {
|
||||
configInput = {
|
||||
base: searchParams.get("base"),
|
||||
style: searchParams.get("style"),
|
||||
iconLibrary: searchParams.get("iconLibrary"),
|
||||
baseColor: searchParams.get("baseColor"),
|
||||
theme: searchParams.get("theme"),
|
||||
chartColor: searchParams.get("chartColor") ?? undefined,
|
||||
font: searchParams.get("font"),
|
||||
fontHeading: searchParams.get("fontHeading") ?? undefined,
|
||||
menuAccent: searchParams.get("menuAccent"),
|
||||
menuColor: searchParams.get("menuColor"),
|
||||
radius: searchParams.get("radius"),
|
||||
template: searchParams.get("template") ?? undefined,
|
||||
rtl: searchParams.get("rtl") === "true",
|
||||
pointer: searchParams.get("pointer") === "true",
|
||||
}
|
||||
}
|
||||
|
||||
const result = designSystemConfigSchema.safeParse(configInput)
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false as const, error: result.error.issues[0].message }
|
||||
}
|
||||
|
||||
return { success: true as const, data: result.data as DesignSystemConfig }
|
||||
}
|
||||
17
apps/v4/app/(app)/(create)/lib/parse-preset-input.test.ts
Normal file
17
apps/v4/app/(app)/(create)/lib/parse-preset-input.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { parsePresetInput } from "./parse-preset-input"
|
||||
|
||||
describe("parsePresetInput", () => {
|
||||
it("accepts a raw preset code", () => {
|
||||
expect(parsePresetInput("b0")).toBe("b0")
|
||||
})
|
||||
|
||||
it("accepts a --preset flag", () => {
|
||||
expect(parsePresetInput(" --preset b0 ")).toBe("b0")
|
||||
})
|
||||
|
||||
it("rejects invalid preset input", () => {
|
||||
expect(parsePresetInput("open sesame")).toBeNull()
|
||||
})
|
||||
})
|
||||
15
apps/v4/app/(app)/(create)/lib/parse-preset-input.ts
Normal file
15
apps/v4/app/(app)/(create)/lib/parse-preset-input.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { isPresetCode } from "shadcn/preset"
|
||||
|
||||
const PRESET_FLAG_PATTERN = /^--preset\b\s+(.+)$/i
|
||||
|
||||
export function parsePresetInput(value: string) {
|
||||
const input = value.trim()
|
||||
|
||||
if (!input) {
|
||||
return null
|
||||
}
|
||||
|
||||
const preset = input.match(PRESET_FLAG_PATTERN)?.[1]?.trim() ?? input
|
||||
|
||||
return isPresetCode(preset) ? preset : null
|
||||
}
|
||||
34
apps/v4/app/(app)/(create)/lib/preset-code.ts
Normal file
34
apps/v4/app/(app)/(create)/lib/preset-code.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { encodePreset, type PresetConfig } from "shadcn/preset"
|
||||
|
||||
import { type DesignSystemConfig } from "@/registry/config"
|
||||
|
||||
type PresetCodeConfig = Pick<
|
||||
DesignSystemConfig,
|
||||
| "style"
|
||||
| "baseColor"
|
||||
| "theme"
|
||||
| "chartColor"
|
||||
| "iconLibrary"
|
||||
| "font"
|
||||
| "fontHeading"
|
||||
| "radius"
|
||||
| "menuAccent"
|
||||
| "menuColor"
|
||||
>
|
||||
|
||||
export function getPresetCode(config: PresetCodeConfig) {
|
||||
const presetConfig: Partial<PresetConfig> = {
|
||||
style: config.style as PresetConfig["style"],
|
||||
baseColor: config.baseColor as PresetConfig["baseColor"],
|
||||
theme: config.theme as PresetConfig["theme"],
|
||||
chartColor: config.chartColor as PresetConfig["chartColor"],
|
||||
iconLibrary: config.iconLibrary as PresetConfig["iconLibrary"],
|
||||
font: config.font as PresetConfig["font"],
|
||||
fontHeading: config.fontHeading as PresetConfig["fontHeading"],
|
||||
radius: config.radius as PresetConfig["radius"],
|
||||
menuAccent: config.menuAccent as PresetConfig["menuAccent"],
|
||||
menuColor: config.menuColor as PresetConfig["menuColor"],
|
||||
}
|
||||
|
||||
return encodePreset(presetConfig)
|
||||
}
|
||||
34
apps/v4/app/(app)/(create)/lib/preset-query.test.ts
Normal file
34
apps/v4/app/(app)/(create)/lib/preset-query.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolvePresetOverrides } from "./preset-query"
|
||||
|
||||
describe("resolvePresetOverrides", () => {
|
||||
it("prefers explicit fontHeading and chartColor query params", () => {
|
||||
const overrides = resolvePresetOverrides(
|
||||
new URLSearchParams("fontHeading=playfair-display&chartColor=emerald"),
|
||||
{
|
||||
theme: "neutral",
|
||||
chartColor: "blue",
|
||||
fontHeading: "inherit",
|
||||
}
|
||||
)
|
||||
|
||||
expect(overrides).toEqual({
|
||||
fontHeading: "playfair-display",
|
||||
chartColor: "emerald",
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to decoded preset values when no overrides are present", () => {
|
||||
const overrides = resolvePresetOverrides(new URLSearchParams(), {
|
||||
theme: "neutral",
|
||||
chartColor: "blue",
|
||||
fontHeading: "inherit",
|
||||
})
|
||||
|
||||
expect(overrides).toEqual({
|
||||
fontHeading: "inherit",
|
||||
chartColor: "blue",
|
||||
})
|
||||
})
|
||||
})
|
||||
30
apps/v4/app/(app)/(create)/lib/preset-query.ts
Normal file
30
apps/v4/app/(app)/(create)/lib/preset-query.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { V1_CHART_COLOR_MAP, type PresetConfig } from "shadcn/preset"
|
||||
|
||||
import { type ChartColorName, type FontHeadingValue } from "@/registry/config"
|
||||
|
||||
type SearchParamsLike = Pick<URLSearchParams, "get" | "has">
|
||||
|
||||
export function resolvePresetOverrides(
|
||||
searchParams: SearchParamsLike,
|
||||
decoded: Pick<PresetConfig, "theme" | "chartColor" | "fontHeading">
|
||||
) {
|
||||
const hasFontHeadingOverride = searchParams.has("fontHeading")
|
||||
const hasChartColorOverride = searchParams.has("chartColor")
|
||||
|
||||
const fontHeading = hasFontHeadingOverride
|
||||
? ((searchParams.get("fontHeading") ??
|
||||
decoded.fontHeading) as FontHeadingValue)
|
||||
: decoded.fontHeading
|
||||
|
||||
const chartColor = hasChartColorOverride
|
||||
? ((searchParams.get("chartColor") ??
|
||||
decoded.chartColor ??
|
||||
V1_CHART_COLOR_MAP[decoded.theme] ??
|
||||
decoded.theme) as ChartColorName)
|
||||
: (decoded.chartColor ?? V1_CHART_COLOR_MAP[decoded.theme] ?? decoded.theme)
|
||||
|
||||
return {
|
||||
fontHeading,
|
||||
chartColor,
|
||||
}
|
||||
}
|
||||
113
apps/v4/app/(app)/(create)/lib/randomize-biases.ts
Normal file
113
apps/v4/app/(app)/(create)/lib/randomize-biases.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
BaseColor,
|
||||
BaseColorName,
|
||||
Radius,
|
||||
StyleName,
|
||||
Theme,
|
||||
ThemeName,
|
||||
} from "@/registry/config"
|
||||
|
||||
import { type FONTS } from "./fonts"
|
||||
|
||||
export type RandomizeContext = {
|
||||
style?: StyleName
|
||||
baseColor?: BaseColorName
|
||||
theme?: ThemeName
|
||||
chartColor?: string
|
||||
iconLibrary?: string
|
||||
font?: string
|
||||
menuAccent?: string
|
||||
menuColor?: string
|
||||
radius?: string
|
||||
}
|
||||
|
||||
export type BiasFilter<T> = (
|
||||
items: readonly T[],
|
||||
context: RandomizeContext
|
||||
) => readonly T[]
|
||||
|
||||
export type RandomizeBiases = {
|
||||
baseColors?: BiasFilter<BaseColor>
|
||||
chartColors?: BiasFilter<Theme>
|
||||
fonts?: BiasFilter<(typeof FONTS)[number]>
|
||||
radius?: BiasFilter<Radius>
|
||||
}
|
||||
|
||||
// Theme → chart color pairings for randomization.
|
||||
const CHART_COLOR_PAIRINGS: Record<string, string[]> = {
|
||||
red: ["teal", "sky"],
|
||||
orange: ["teal", "blue"],
|
||||
amber: ["cyan", "indigo"],
|
||||
yellow: ["sky", "violet"],
|
||||
lime: ["indigo", "pink"],
|
||||
green: ["purple", "rose"],
|
||||
emerald: ["purple", "red"],
|
||||
teal: ["fuchsia", "red"],
|
||||
cyan: ["rose", "amber"],
|
||||
sky: ["red", "yellow"],
|
||||
blue: ["orange", "yellow"],
|
||||
indigo: ["amber", "yellow"],
|
||||
violet: ["yellow", "lime"],
|
||||
purple: ["green", "lime"],
|
||||
fuchsia: ["lime", "teal"],
|
||||
pink: ["green", "cyan"],
|
||||
rose: ["emerald", "sky"],
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for randomization biases.
|
||||
* Add biases here to influence random selection based on context.
|
||||
*/
|
||||
export const RANDOMIZE_BIASES: RandomizeBiases = {
|
||||
baseColors: (baseColors) => {
|
||||
return baseColors.filter((c) => c.name !== "gray")
|
||||
},
|
||||
fonts: (fonts, context) => {
|
||||
// When style is lyra, only use mono fonts.
|
||||
if (context.style === "lyra") {
|
||||
return fonts.filter((font) => font.value === "jetbrains-mono")
|
||||
}
|
||||
|
||||
return fonts
|
||||
},
|
||||
radius: (radii, context) => {
|
||||
// When style is lyra, always use "none" radius.
|
||||
if (context.style === "lyra") {
|
||||
return radii.filter((radius) => radius.name === "none")
|
||||
}
|
||||
|
||||
// Rhea does not support the "large" radius.
|
||||
if (context.style === "rhea") {
|
||||
return radii.filter((radius) => radius.name !== "large")
|
||||
}
|
||||
|
||||
return radii
|
||||
},
|
||||
chartColors: (chartColors, context) => {
|
||||
// When theme has a pairing, restrict chart colors to the paired values.
|
||||
const pairing = context.theme ? CHART_COLOR_PAIRINGS[context.theme] : null
|
||||
if (pairing) {
|
||||
const filtered = chartColors.filter((c) => pairing.includes(c.name))
|
||||
if (filtered.length > 0) {
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
return chartColors
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies biases to a list of items based on the current context.
|
||||
*/
|
||||
export function applyBias<T>(
|
||||
items: readonly T[],
|
||||
context: RandomizeContext,
|
||||
biasFilter?: BiasFilter<T>
|
||||
): readonly T[] {
|
||||
if (!biasFilter) {
|
||||
return items
|
||||
}
|
||||
|
||||
return biasFilter(items, context)
|
||||
}
|
||||
53
apps/v4/app/(app)/(create)/lib/search-params.test.ts
Normal file
53
apps/v4/app/(app)/(create)/lib/search-params.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { DEFAULT_CONFIG } from "@/registry/config"
|
||||
|
||||
import { buildPresetUrlUpdate } from "./search-params"
|
||||
|
||||
describe("buildPresetUrlUpdate", () => {
|
||||
it("encodes design system params into preset and clears individual keys", () => {
|
||||
const update = buildPresetUrlUpdate({
|
||||
...DEFAULT_CONFIG,
|
||||
preset: "b0",
|
||||
template: "next",
|
||||
item: "preview-02",
|
||||
size: 100,
|
||||
custom: false,
|
||||
})
|
||||
|
||||
expect(update.preset).toBeTypeOf("string")
|
||||
expect(update.style).toBeNull()
|
||||
expect(update.theme).toBeNull()
|
||||
expect(update.baseColor).toBeNull()
|
||||
})
|
||||
|
||||
it("preserves template when syncing preset from ?template=start", () => {
|
||||
const update = buildPresetUrlUpdate({
|
||||
...DEFAULT_CONFIG,
|
||||
preset: "b0",
|
||||
template: "start",
|
||||
item: "preview-02",
|
||||
size: 100,
|
||||
custom: false,
|
||||
})
|
||||
|
||||
expect(update.template).toBe("start")
|
||||
expect(update.preset).toBeTypeOf("string")
|
||||
})
|
||||
|
||||
it("applies non-design-system updates from resolvedUpdates", () => {
|
||||
const update = buildPresetUrlUpdate(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
preset: "b0",
|
||||
template: "next",
|
||||
item: "preview-02",
|
||||
size: 100,
|
||||
custom: false,
|
||||
},
|
||||
{ template: "vite" }
|
||||
)
|
||||
|
||||
expect(update.template).toBe("vite")
|
||||
})
|
||||
})
|
||||
343
apps/v4/app/(app)/(create)/lib/search-params.ts
Normal file
343
apps/v4/app/(app)/(create)/lib/search-params.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import * as React from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useQueryStates } from "nuqs"
|
||||
import {
|
||||
createLoader,
|
||||
createSerializer,
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringLiteral,
|
||||
type inferParserType,
|
||||
type Options,
|
||||
} from "nuqs/server"
|
||||
import { decodePreset, isPresetCode } from "shadcn/preset"
|
||||
|
||||
import {
|
||||
BASE_COLORS,
|
||||
BASES,
|
||||
DEFAULT_CONFIG,
|
||||
getThemesForBaseColor,
|
||||
iconLibraries,
|
||||
MENU_ACCENTS,
|
||||
MENU_COLORS,
|
||||
RADII,
|
||||
STYLES,
|
||||
THEMES,
|
||||
type BaseColorName,
|
||||
type BaseName,
|
||||
type ChartColorName,
|
||||
type FontHeadingValue,
|
||||
type FontValue,
|
||||
type IconLibraryName,
|
||||
type MenuAccentValue,
|
||||
type MenuColorValue,
|
||||
type RadiusValue,
|
||||
type StyleName,
|
||||
type ThemeName,
|
||||
} from "@/registry/config"
|
||||
import { FONTS } from "@/app/(app)/(create)/lib/fonts"
|
||||
import { getPresetCode } from "@/app/(app)/(create)/lib/preset-code"
|
||||
import { resolvePresetOverrides } from "@/app/(app)/(create)/lib/preset-query"
|
||||
|
||||
const designSystemSearchParams = {
|
||||
preset: parseAsString.withDefault("b0"),
|
||||
base: parseAsStringLiteral<BaseName>(BASES.map((b) => b.name)).withDefault(
|
||||
DEFAULT_CONFIG.base
|
||||
),
|
||||
item: parseAsString.withDefault("preview-02").withOptions({ shallow: true }),
|
||||
iconLibrary: parseAsStringLiteral<IconLibraryName>(
|
||||
Object.values(iconLibraries).map((i) => i.name)
|
||||
).withDefault(DEFAULT_CONFIG.iconLibrary),
|
||||
style: parseAsStringLiteral<StyleName>(STYLES.map((s) => s.name)).withDefault(
|
||||
DEFAULT_CONFIG.style
|
||||
),
|
||||
theme: parseAsStringLiteral<ThemeName>(THEMES.map((t) => t.name)).withDefault(
|
||||
DEFAULT_CONFIG.theme
|
||||
),
|
||||
chartColor: parseAsStringLiteral<ChartColorName>(
|
||||
THEMES.map((t) => t.name)
|
||||
).withDefault(DEFAULT_CONFIG.chartColor ?? "neutral"),
|
||||
font: parseAsStringLiteral<FontValue>(FONTS.map((f) => f.value)).withDefault(
|
||||
DEFAULT_CONFIG.font
|
||||
),
|
||||
fontHeading: parseAsStringLiteral<FontHeadingValue>([
|
||||
"inherit",
|
||||
...FONTS.map((f) => f.value),
|
||||
]).withDefault(DEFAULT_CONFIG.fontHeading),
|
||||
baseColor: parseAsStringLiteral<BaseColorName>(
|
||||
BASE_COLORS.map((b) => b.name)
|
||||
).withDefault(DEFAULT_CONFIG.baseColor),
|
||||
menuAccent: parseAsStringLiteral<MenuAccentValue>(
|
||||
MENU_ACCENTS.map((a) => a.value)
|
||||
).withDefault(DEFAULT_CONFIG.menuAccent),
|
||||
menuColor: parseAsStringLiteral<MenuColorValue>(
|
||||
MENU_COLORS.map((m) => m.value)
|
||||
).withDefault(DEFAULT_CONFIG.menuColor),
|
||||
radius: parseAsStringLiteral<RadiusValue>(
|
||||
RADII.map((r) => r.name)
|
||||
).withDefault("default"),
|
||||
template: parseAsStringLiteral([
|
||||
"next",
|
||||
"next-monorepo",
|
||||
"start",
|
||||
"start-monorepo",
|
||||
"react-router",
|
||||
"react-router-monorepo",
|
||||
"vite",
|
||||
"vite-monorepo",
|
||||
"astro",
|
||||
"astro-monorepo",
|
||||
"laravel",
|
||||
] as const).withDefault("next"),
|
||||
rtl: parseAsBoolean.withDefault(false),
|
||||
pointer: parseAsBoolean.withDefault(false),
|
||||
size: parseAsInteger.withDefault(100),
|
||||
custom: parseAsBoolean.withDefault(false),
|
||||
}
|
||||
|
||||
// Design system param keys that get encoded into the preset code.
|
||||
const DESIGN_SYSTEM_KEYS = [
|
||||
"style",
|
||||
"baseColor",
|
||||
"theme",
|
||||
"chartColor",
|
||||
"iconLibrary",
|
||||
"font",
|
||||
"fontHeading",
|
||||
"radius",
|
||||
"menuAccent",
|
||||
"menuColor",
|
||||
] as const
|
||||
|
||||
function normalizeFontHeading(
|
||||
font: FontValue,
|
||||
fontHeading: FontHeadingValue
|
||||
): FontHeadingValue {
|
||||
// Persist "same as body" as an explicit inherit sentinel so the body font
|
||||
// can change later without freezing headings to a concrete previous value.
|
||||
return fontHeading === font ? "inherit" : fontHeading
|
||||
}
|
||||
|
||||
// Non-design-system keys that get passed through as-is.
|
||||
// `base` is not encoded in preset codes — it's an architectural choice, not visual.
|
||||
const NON_DESIGN_SYSTEM_KEYS = [
|
||||
"base",
|
||||
"item",
|
||||
"preset",
|
||||
"template",
|
||||
"rtl",
|
||||
"pointer",
|
||||
"size",
|
||||
"custom",
|
||||
] as const
|
||||
|
||||
// Shared across hook instances so only one mount effect encodes the initial URL.
|
||||
let hasSyncedPresetToUrl = false
|
||||
|
||||
export const loadDesignSystemSearchParams = createLoader(
|
||||
designSystemSearchParams
|
||||
)
|
||||
|
||||
export const serializeDesignSystemSearchParams = createSerializer(
|
||||
designSystemSearchParams
|
||||
)
|
||||
|
||||
export type DesignSystemSearchParams = inferParserType<
|
||||
typeof designSystemSearchParams
|
||||
>
|
||||
|
||||
export function isTranslucentMenuColor(
|
||||
menuColor?: MenuColorValue | null
|
||||
): menuColor is "default-translucent" | "inverted-translucent" {
|
||||
return (
|
||||
menuColor === "default-translucent" || menuColor === "inverted-translucent"
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePartialDesignSystemParams(
|
||||
params: Partial<DesignSystemSearchParams>
|
||||
): Partial<DesignSystemSearchParams> {
|
||||
if (
|
||||
params.menuAccent === "bold" &&
|
||||
isTranslucentMenuColor(params.menuColor ?? undefined)
|
||||
) {
|
||||
return {
|
||||
...params,
|
||||
menuAccent: "subtle",
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
function normalizeDesignSystemParams(
|
||||
params: DesignSystemSearchParams
|
||||
): DesignSystemSearchParams {
|
||||
let result = {
|
||||
...params,
|
||||
fontHeading: normalizeFontHeading(params.font, params.fontHeading),
|
||||
}
|
||||
|
||||
// Validate theme and chartColor against baseColor.
|
||||
if (result.baseColor) {
|
||||
const available = getThemesForBaseColor(result.baseColor)
|
||||
const themeValid = available.some((t) => t.name === result.theme)
|
||||
const chartColorValid = available.some((t) => t.name === result.chartColor)
|
||||
|
||||
if (!themeValid || !chartColorValid) {
|
||||
const fallback = (available[0]?.name ?? result.baseColor) as ThemeName
|
||||
result = {
|
||||
...result,
|
||||
...(!themeValid && { theme: fallback }),
|
||||
...(!chartColorValid && { chartColor: fallback as ChartColorName }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
result.menuAccent === "bold" &&
|
||||
isTranslucentMenuColor(result.menuColor)
|
||||
) {
|
||||
return {
|
||||
...result,
|
||||
menuAccent: "subtle",
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// If preset param exists, decode it and overlay on raw params.
|
||||
// V1 presets don't encode chartColor — fall back to the colored
|
||||
// theme that base-color themes originally borrowed charts from.
|
||||
type SearchParamsLike = Pick<URLSearchParams, "get" | "has">
|
||||
|
||||
function resolvePresetParams(
|
||||
rawParams: DesignSystemSearchParams,
|
||||
searchParams: SearchParamsLike
|
||||
) {
|
||||
if (rawParams.preset && isPresetCode(rawParams.preset)) {
|
||||
const decoded = decodePreset(rawParams.preset)
|
||||
if (decoded) {
|
||||
const presetOverrides = resolvePresetOverrides(searchParams, decoded)
|
||||
return normalizeDesignSystemParams({
|
||||
...decoded,
|
||||
...presetOverrides,
|
||||
base: rawParams.base,
|
||||
item: rawParams.item,
|
||||
preset: rawParams.preset,
|
||||
template: rawParams.template,
|
||||
rtl: rawParams.rtl,
|
||||
pointer: rawParams.pointer,
|
||||
size: rawParams.size,
|
||||
custom: rawParams.custom,
|
||||
})
|
||||
}
|
||||
}
|
||||
return normalizeDesignSystemParams(rawParams)
|
||||
}
|
||||
|
||||
export function buildPresetUrlUpdate(
|
||||
merged: DesignSystemSearchParams,
|
||||
resolvedUpdates: Partial<DesignSystemSearchParams> = {}
|
||||
) {
|
||||
const code = getPresetCode(merged)
|
||||
const rawUpdate: Record<string, unknown> = { preset: code }
|
||||
|
||||
for (const key of DESIGN_SYSTEM_KEYS) {
|
||||
rawUpdate[key] = null
|
||||
}
|
||||
|
||||
for (const key of NON_DESIGN_SYSTEM_KEYS) {
|
||||
if (key === "preset") {
|
||||
continue
|
||||
}
|
||||
|
||||
rawUpdate[key] =
|
||||
key in resolvedUpdates
|
||||
? (resolvedUpdates as Record<string, unknown>)[key]
|
||||
: merged[key]
|
||||
}
|
||||
|
||||
return rawUpdate
|
||||
}
|
||||
|
||||
// Wraps nuqs useQueryStates with transparent preset encoding/decoding.
|
||||
// - Reads: if ?preset=CODE is in the URL, decodes it and returns individual values.
|
||||
// - Writes: when design system params are set, encodes them into a preset code.
|
||||
//
|
||||
// Default options use shallow: true so picker selections do not trigger a full
|
||||
// Next.js server navigation. This prevents the customizer panel from flickering
|
||||
// or resetting while the URL update propagates (#10910, #11060).
|
||||
export function useDesignSystemSearchParams(options: Options = {}) {
|
||||
const searchParams = useSearchParams()
|
||||
const [rawParams, rawSetParams] = useQueryStates(designSystemSearchParams, {
|
||||
shallow: true,
|
||||
history: "push",
|
||||
...options,
|
||||
})
|
||||
|
||||
const params = React.useMemo(
|
||||
() => resolvePresetParams(rawParams, searchParams),
|
||||
[rawParams, searchParams]
|
||||
)
|
||||
|
||||
// Use ref so setParams callback stays stable across renders.
|
||||
const paramsRef = React.useRef(params)
|
||||
React.useEffect(() => {
|
||||
paramsRef.current = params
|
||||
}, [params])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasSyncedPresetToUrl || searchParams.has("preset")) {
|
||||
return
|
||||
}
|
||||
|
||||
hasSyncedPresetToUrl = true
|
||||
|
||||
const merged = normalizeDesignSystemParams(paramsRef.current)
|
||||
void rawSetParams(buildPresetUrlUpdate(merged) as RawSetParamsInput, {
|
||||
history: "replace",
|
||||
})
|
||||
}, [rawSetParams, searchParams])
|
||||
|
||||
type RawSetParamsInput = Parameters<typeof rawSetParams>[0]
|
||||
|
||||
const setParams = React.useCallback(
|
||||
(
|
||||
updates:
|
||||
| Partial<DesignSystemSearchParams>
|
||||
| ((
|
||||
old: DesignSystemSearchParams
|
||||
) => Partial<DesignSystemSearchParams>),
|
||||
setOptions?: Options
|
||||
) => {
|
||||
const resolvedUpdates = normalizePartialDesignSystemParams(
|
||||
typeof updates === "function" ? updates(paramsRef.current) : updates
|
||||
)
|
||||
|
||||
const hasDesignSystemUpdate = DESIGN_SYSTEM_KEYS.some(
|
||||
(key) => key in resolvedUpdates
|
||||
)
|
||||
|
||||
if (!hasDesignSystemUpdate) {
|
||||
// No design system change, pass through directly.
|
||||
return rawSetParams(resolvedUpdates as RawSetParamsInput, setOptions)
|
||||
}
|
||||
|
||||
// Merge current decoded values with updates.
|
||||
const merged = normalizeDesignSystemParams({
|
||||
...paramsRef.current,
|
||||
...resolvedUpdates,
|
||||
})
|
||||
return rawSetParams(
|
||||
buildPresetUrlUpdate(merged, resolvedUpdates) as RawSetParamsInput,
|
||||
setOptions
|
||||
)
|
||||
},
|
||||
[rawSetParams]
|
||||
)
|
||||
|
||||
return [params, setParams] as const
|
||||
}
|
||||
28
apps/v4/app/(app)/(create)/lib/shuffle-presets.ts
Normal file
28
apps/v4/app/(app)/(create)/lib/shuffle-presets.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Curated preset codes from https://dialectcn.xyz.
|
||||
export const SHUFFLE_PRESETS = [
|
||||
"b6sUj34d9",
|
||||
"b2tqYzpa88",
|
||||
"b1W4tDrk",
|
||||
"b1aIuQ2XC",
|
||||
"b7jsW1RxJ5",
|
||||
"b870VEw0in",
|
||||
"b3Zheoix4U",
|
||||
"b1x9M2c4aI",
|
||||
"b1W7jDEW",
|
||||
"b51GFh7y6",
|
||||
"b2fms620zo",
|
||||
"b1Q5GC",
|
||||
"buKEvLs",
|
||||
"b5rR41Mtnc",
|
||||
"b6tOz2I0x",
|
||||
"b2hNTREGRN",
|
||||
"bdIJ7Sq",
|
||||
"b6TqMNb5Wb",
|
||||
"bJIirQ",
|
||||
"b4aRK5K0fb",
|
||||
"b5HCiD38LI",
|
||||
"bdHjvCi",
|
||||
"b7QDHijUjj",
|
||||
"b4ZVZIPi9h",
|
||||
"b1W4bcno",
|
||||
]
|
||||
55
apps/v4/app/(app)/(create)/lib/templates.ts
Normal file
55
apps/v4/app/(app)/(create)/lib/templates.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export const TEMPLATES = [
|
||||
{
|
||||
value: "next",
|
||||
title: "Next.js",
|
||||
logo: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Next.js</title><path d="M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z" fill="currentColor"/></svg>',
|
||||
},
|
||||
{
|
||||
value: "vite",
|
||||
title: "Vite",
|
||||
logo: '<svg xmlns="http://www.w3.org/2000/svg" width="410" height="404" fill="none" viewBox="0 0 410 404"><path fill="var(--foreground)" d="m399.641 59.525-183.998 329.02c-3.799 6.793-13.559 6.833-17.415.073L10.582 59.556C6.38 52.19 12.68 43.266 21.028 44.76l184.195 32.923c1.175.21 2.378.208 3.553-.006l180.343-32.87c8.32-1.517 14.649 7.337 10.522 14.719"/><path fill="var(--color-neutral-800)" d="M292.965 1.574 156.801 28.255a5 5 0 0 0-4.03 4.611l-8.376 141.464c-.197 3.332 2.863 5.918 6.115 5.168l37.91-8.749c3.547-.818 6.752 2.306 6.023 5.873l-11.263 55.153c-.758 3.712 2.727 6.886 6.352 5.785l23.415-7.114c3.63-1.102 7.118 2.081 6.35 5.796l-17.899 86.633c-1.12 5.419 6.088 8.374 9.094 3.728l2.008-3.103 110.954-221.428c1.858-3.707-1.346-7.935-5.418-7.15l-39.022 7.532c-3.667.707-6.787-2.708-5.752-6.296l25.469-88.291c1.036-3.594-2.095-7.012-5.766-6.293"/></svg>',
|
||||
},
|
||||
{
|
||||
value: "start",
|
||||
title: "TanStack Start",
|
||||
logo: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>TanStack</title><path d="M11.078.042c.316-.042.65-.014.97-.014 1.181 0 2.341.184 3.472.532a12.3 12.3 0 0 1 3.973 2.086 11.9 11.9 0 0 1 3.432 4.33c1.446 3.15 1.436 6.97-.046 10.107-.958 2.029-2.495 3.727-4.356 4.965-1.518 1.01-3.293 1.629-5.1 1.848-2.298.279-4.784-.129-6.85-1.188-3.88-1.99-6.518-5.994-6.57-10.382-.01-.846.003-1.697.17-2.534.273-1.365.748-2.683 1.463-3.88a12 12 0 0 1 2.966-3.36A12.3 12.3 0 0 1 9.357.3a12 12 0 0 1 1.255-.2l.133-.016zM7.064 19.99c-.535.057-1.098.154-1.557.454.103.025.222 0 .33 0 .258 0 .52-.01.778.002.647.028 1.32.131 1.945.303.8.22 1.505.65 2.275.942.813.307 1.622.402 2.484.402.435 0 .866-.001 1.287-.12-.22-.117-.534-.095-.778-.144a11 11 0 0 1-1.556-.416 12 12 0 0 1-1.093-.467l-.23-.108a15 15 0 0 0-1.012-.44c-.905-.343-1.908-.512-2.873-.408m.808-2.274c-1.059 0-2.13.187-3.083.667q-.346.177-.659.41c-.063.046-.175.106-.199.188s.061.151.11.204c.238-.127.464-.261.718-.357 1.64-.624 3.63-.493 5.268.078.817.285 1.569.712 2.365 1.046.89.374 1.798.616 2.753.74 1.127.147 2.412.028 3.442-.48.362-.179.865-.451 1.018-.847-.189.017-.36.098-.539.154a9 9 0 0 1-.868.222c-.994.2-2.052.24-3.053.06-.943-.17-1.82-.513-2.693-.873l-.111-.046-.223-.092-.112-.046a26 26 0 0 0-1.35-.527c-.89-.31-1.842-.5-2.784-.5M9.728 1.452c-1.27.28-2.407.826-3.502 1.514-.637.4-1.245.81-1.796 1.323-.82.765-1.447 1.695-1.993 2.666-.563 1-.924 2.166-1.098 3.297-.172 1.11-.2 2.277-.004 3.388.245 1.388.712 2.691 1.448 3.897.248-.116.424-.38.629-.557.414-.359.85-.691 1.317-.978a3.5 3.5 0 0 1 .539-.264c.07-.029.187-.055.22-.132.053-.124-.045-.34-.062-.468a7 7 0 0 1-.068-1.109 9.7 9.7 0 0 1 .61-3.177c.29-.76.73-1.45 1.254-2.069.177-.21.365-.405.56-.6.115-.114.258-.212.33-.359-.376 0-.751.108-1.108.218-.769.237-1.518.588-2.155 1.084-.291.226-.504.522-.779.76-.084.073-.235.17-.352.116-.176-.083-.149-.43-.169-.59-.078-.612.154-1.387.45-1.918.473-.852 1.348-1.58 2.376-1.555.444.011.833.166 1.257.266-.107-.153-.252-.264-.389-.39a5.4 5.4 0 0 0-1.107-.8c-.163-.085-.338-.136-.509-.2-.086-.03-.195-.074-.227-.17-.06-.177.26-.342.377-.417.453-.289 1.01-.527 1.556-.54.854-.021 1.688.452 2.04 1.258.123.284.16.583.184.885l.004.057.006.085.002.029.005.057.004.056c.268-.218.457-.54.718-.774.612-.547 1.45-.79 2.245-.544a2.97 2.97 0 0 1 1.71 1.378c.097.173.365.595.171.767-.152.134-.344.03-.504-.026a3 3 0 0 0-.372-.094l-.068-.014-.069-.013a3.9 3.9 0 0 0-1.377-.002c-.282.05-.557.15-.838.192v.06c.768.006 1.51.444 1.89 1.109.157.275.235.59.295.9.075.38.022.796-.082 1.168-.035.125-.098.336-.247.365-.106.02-.195-.085-.256-.155a4.6 4.6 0 0 0-.492-.522 20 20 0 0 0-1.467-1.14c-.267-.19-.56-.44-.868-.556.087.208.171.402.2.63.088.667-.192 1.296-.612 1.798a2.6 2.6 0 0 1-.426.427c-.067.05-.151.114-.24.1-.277-.044-.31-.463-.353-.677-.144-.726-.086-1.447.114-2.158-.178.09-.307.287-.418.45a5.3 5.3 0 0 0-.612 1.138c-.61 1.617-.604 3.51.186 5.066.088.174.221.15.395.15h.157a3 3 0 0 1 .472.018c.08.01.193 0 .257.06.077.072.036.194.018.282-.05.246-.066.469-.066.72.328-.051.419-.576.535-.84.131-.298.265-.597.387-.9.06-.148.14-.314.119-.479-.024-.185-.157-.381-.25-.54-.177-.298-.378-.606-.508-.929-.104-.258-.007-.58.286-.672.161-.05.334.049.439.166.22.244.363.609.523.896l1.249 2.248q.159.286.32.57c.043.074.086.188.173.219.077.028.182-.012.26-.027.198-.04.398-.083.598-.12.24-.043.605-.035.778-.222-.253-.08-.545-.075-.808-.057-.158.01-.333.067-.479-.025-.216-.137-.36-.455-.492-.667-.326-.525-.633-1.057-.945-1.59l-.05-.084-.1-.17q-.075-.126-.149-.255c-.037-.066-.092-.153-.039-.227.056-.076.179-.08.29-.081h.021q.066.001.117-.004a10 10 0 0 1 1.347-.107c-.035-.122-.135-.26-.103-.39.071-.292.49-.383.686-.174.131.14.207.334.292.504.113.223.24.44.361.66.211.383.441.757.658 1.138l.055.094.028.047c.093.156.187.314.238.489-.753-.035-1.318-.909-1.646-1.499-.027.095.016.179.05.27q.103.282.262.54c.152.244.326.495.556.673.408.315.945.317 1.436.283.315-.022.708-.165 1.018-.068s.434.438.25.7c-.138.196-.321.27-.55.3.162.346.373.667.527 1.02.064.146.13.37.283.448.102.051.248.003.358 0-.11-.292-.317-.54-.419-.839.31.015.61.176.898.28.567.202 1.128.424 1.687.648l.258.104c.23.092.462.183.689.283.083.037.198.123.29.07.074-.043.123-.146.169-.215a10.3 10.3 0 0 0 1.393-3.208c.75-2.989.106-6.287-1.695-8.783-.692-.96-1.562-1.789-2.522-2.476-2.401-1.718-5.551-2.407-8.44-1.768m4.908 14.904c-.636.166-1.292.317-1.945.401.086.293.296.577.45.84.059.101.122.237.24.281.132.05.292-.03.417-.072-.058-.158-.155-.3-.235-.45-.033-.06-.084-.133-.056-.206.05-.137.263-.13.381-.153.31-.063.617-.142.928-.204.114-.023.274-.085.389-.047.086.03.138.1.187.174l.022.033q.043.07.097.122c.125.113.313.13.472.162-.097-.219-.259-.41-.362-.63-.06-.127-.11-.315-.242-.388-.182-.102-.557.089-.743.137m-4.01-1.457c-.03.38-.147.689-.33 1.019.21.026.423.036.629.087.154.038.296.11.449.153-.082-.224-.233-.423-.35-.63-.12-.208-.226-.462-.398-.63" fill="currentColor"/></svg>',
|
||||
},
|
||||
{
|
||||
value: "laravel",
|
||||
title: "Laravel",
|
||||
logo: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Laravel</title><path d="M23.642 5.43a.364.364 0 0 1 .014.1v5.149c0 .135-.073.26-.189.326l-4.323 2.49v4.934a.378.378 0 0 1-.188.326L9.93 23.949a.316.316 0 0 1-.066.027c-.008.002-.016.008-.024.01a.348.348 0 0 1-.192 0c-.011-.002-.02-.008-.03-.012-.02-.008-.042-.014-.062-.025L.533 18.755a.376.376 0 0 1-.189-.326V2.974c0-.033.005-.066.014-.098.003-.012.01-.02.014-.032a.369.369 0 0 1 .023-.058c.004-.013.015-.022.023-.033l.033-.045c.012-.01.025-.018.037-.027.014-.012.027-.024.041-.034h.001L5.044.05a.375.375 0 0 1 .375 0L9.933 2.697h.002c.015.01.027.021.04.033l.038.027c.013.014.02.03.033.045.008.011.02.021.025.033.01.02.017.038.024.058.003.011.01.021.013.032.01.031.014.064.014.098v9.652l3.76-2.164V5.527c0-.033.004-.066.013-.098.003-.01.01-.02.013-.032a.487.487 0 0 1 .024-.059c.007-.012.018-.02.025-.033.012-.015.021-.03.033-.043.012-.012.025-.02.037-.028.014-.011.026-.023.041-.032h.001l4.513-2.647a.375.375 0 0 1 .375 0l4.513 2.647c.016.011.029.021.042.031.012.01.025.018.036.028.013.014.022.03.034.045.008.012.019.021.024.033a.3.3 0 0 1 .024.06c.006.01.012.021.015.032zm-.74 5.032V5.860l-1.578.908-2.182 1.256v4.603zm-4.514 7.75v-4.605l-2.148 1.227-6.876 3.93v4.649zm-17.642-15.3v15.15l8.25 4.757v-4.645L4.699 15.87l-.003-.002-.002-.001c-.014-.01-.025-.021-.04-.033-.012-.01-.026-.018-.036-.028l-.001-.002c-.013-.012-.021-.028-.032-.043-.01-.013-.022-.023-.03-.037v-.002c-.01-.014-.016-.032-.023-.048-.006-.012-.016-.023-.02-.035l-.002-.001c-.005-.018-.008-.037-.011-.057L4.5 15.58v-9.51l-2.182-1.256-1.578-.908zm4.322-2.474L1.313 2.974l3.756 2.163 3.755-2.163zm2.068 11.22 2.182-1.256V1.974L7.937 3.23 5.755 4.486v11.186zm10.895-7.583-3.755 2.163 3.755 2.164 3.755-2.164zm-.375 4.976-2.182-1.256-1.578-.908v4.603l2.182 1.256 1.578.908zm-8.438 6.186 5.494-3.14 2.944-1.682-3.755-2.163-4.323 2.489-4.136 2.384z" fill="currentColor"/></svg>',
|
||||
},
|
||||
{
|
||||
value: "react-router",
|
||||
title: "React Router",
|
||||
logo: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.118 5.466a2.306 2.306 0 0 0-.623.08c-.278.067-.702.332-.953.583-.41.423-.49.609-.662 1.469-.08.423.41 1.43.847 1.734.45.317 1.085.502 2.065.608 1.429.16 1.84.636 1.84 2.197 0 1.377-.385 1.747-1.96 1.906-1.707.172-2.58.834-2.765 2.117-.106.781.41 1.76 1.125 2.091 1.627.768 3.15-.198 3.467-2.196.211-1.284.622-1.642 1.998-1.747 1.588-.133 2.409-.675 2.713-1.787.278-1.02-.304-2.157-1.297-2.554-.264-.106-.873-.238-1.35-.291-1.495-.16-1.879-.424-2.038-1.39-.225-1.337-.317-1.562-.794-2.09a2.174 2.174 0 0 0-1.613-.73zm-4.785 4.36a2.145 2.145 0 0 0-.497.048c-1.469.318-2.17 2.051-1.35 3.295 1.178 1.774 3.944.953 3.97-1.177.012-1.193-.98-2.143-2.123-2.166zM2.089 14.19a2.22 2.22 0 0 0-.427.052c-2.158.476-2.237 3.626-.106 4.182.53.145.582.145 1.111.013 1.191-.318 1.866-1.456 1.549-2.607-.278-1.02-1.144-1.664-2.127-1.64zm19.824.008c-.233.002-.477.058-.784.162-1.39.477-1.866 2.092-.98 3.336.557.794 1.96 1.058 2.82.516 1.416-.874 1.363-3.057-.093-3.746-.38-.186-.663-.271-.963-.268z" fill="currentColor"/></svg>',
|
||||
},
|
||||
{
|
||||
value: "astro",
|
||||
title: "Astro",
|
||||
logo: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Astro</title><path d="M16.074 16.86C15.354 17.476 13.917 17.895 12.262 17.895C10.23 17.895 8.527 17.263 8.075 16.412C7.914 16.9 7.877 17.458 7.877 17.814C7.877 17.814 7.771 19.564 8.988 20.782C8.988 20.15 9.501 19.637 10.133 19.637C11.216 19.637 11.215 20.582 11.214 21.349V21.418C11.214 22.582 11.925 23.579 12.937 24C12.7812 23.6794 12.7005 23.3275 12.701 22.971C12.701 21.861 13.353 21.448 14.111 20.968C14.713 20.585 15.383 20.161 15.844 19.308C16.0926 18.8493 16.2225 18.3357 16.222 17.814C16.2221 17.4903 16.1722 17.1685 16.074 16.86ZM15.551 0.6C15.747 0.844 15.847 1.172 16.047 1.829L20.415 16.176C18.7743 15.3246 17.0134 14.7284 15.193 14.408L12.35 4.8C12.3273 4.72337 12.2803 4.65616 12.2162 4.60844C12.152 4.56072 12.0742 4.53505 11.9943 4.53528C11.9143 4.5355 11.8366 4.56161 11.7727 4.60969C11.7089 4.65777 11.6623 4.72524 11.64 4.802L8.83 14.405C7.00149 14.724 5.23264 15.3213 3.585 16.176L7.974 1.827C8.174 1.171 8.274 0.843 8.471 0.6C8.64406 0.385433 8.86922 0.218799 9.125 0.116C9.415 0 9.757 0 10.443 0H13.578C14.264 0 14.608 0 14.898 0.117C15.1529 0.219851 15.3783 0.386105 15.551 0.6Z" fill="currentColor"/></svg>',
|
||||
},
|
||||
] as const
|
||||
|
||||
export type TemplateValue = (typeof TEMPLATES)[number]["value"]
|
||||
|
||||
// Extract the base framework from a template value (e.g. "next-monorepo" -> "next").
|
||||
export function getFramework(template: string) {
|
||||
return template.replace("-monorepo", "") as TemplateValue
|
||||
}
|
||||
|
||||
// Frameworks that don't support the monorepo template.
|
||||
export const NO_MONOREPO_FRAMEWORKS = ["laravel"] as const
|
||||
|
||||
// Build the full template value from a framework and monorepo flag.
|
||||
export function getTemplateValue(framework: string, monorepo: boolean) {
|
||||
if (
|
||||
NO_MONOREPO_FRAMEWORKS.includes(
|
||||
framework as (typeof NO_MONOREPO_FRAMEWORKS)[number]
|
||||
)
|
||||
) {
|
||||
return framework
|
||||
}
|
||||
|
||||
return monorepo ? `${framework}-monorepo` : framework
|
||||
}
|
||||
115
apps/v4/app/(app)/(create)/lib/v0.test.ts
Normal file
115
apps/v4/app/(app)/(create)/lib/v0.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { DEFAULT_CONFIG } from "@/registry/config"
|
||||
import { buildV0Payload } from "@/app/(app)/(create)/lib/v0"
|
||||
|
||||
vi.mock("shadcn/schema", async () => {
|
||||
return await vi.importActual("shadcn/schema")
|
||||
})
|
||||
|
||||
vi.mock("shadcn/utils", async () => {
|
||||
const actual = (await vi.importActual("shadcn/utils")) as {
|
||||
transformFont: unknown
|
||||
}
|
||||
|
||||
return {
|
||||
transformFont: actual.transformFont,
|
||||
transformIcons: async ({ sourceFile }: { sourceFile: unknown }) =>
|
||||
sourceFile,
|
||||
transformMenu: async ({ sourceFile }: { sourceFile: unknown }) =>
|
||||
sourceFile,
|
||||
transformRender: async ({ sourceFile }: { sourceFile: unknown }) =>
|
||||
sourceFile,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/registry/bases/__index__", () => ({
|
||||
Index: {
|
||||
base: {
|
||||
card: {
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
},
|
||||
},
|
||||
radix: {
|
||||
card: {
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe("buildV0Payload", () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://example.test"
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string | URL | Request) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
const name = url.split("/").pop()?.replace(".json", "") ?? "component"
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
name,
|
||||
type: "registry:ui",
|
||||
files: [
|
||||
{
|
||||
path: `registry/base-nova/ui/${name}.tsx`,
|
||||
type: "registry:ui",
|
||||
content: `import * as React from "react"\n\nexport function Component() {\n return <div className="cn-font-heading text-xl" />\n}\n`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
delete process.env.NEXT_PUBLIC_APP_URL
|
||||
})
|
||||
|
||||
it("rewrites cn-font-heading to font-heading when heading inherits the body font", async () => {
|
||||
const payload = await buildV0Payload({
|
||||
...DEFAULT_CONFIG,
|
||||
item: undefined,
|
||||
fontHeading: "inherit",
|
||||
})
|
||||
|
||||
const cardFile = payload.files?.find(
|
||||
(file) => file.target === "components/ui/card.tsx"
|
||||
)
|
||||
|
||||
expect(cardFile?.content).toContain("font-heading")
|
||||
expect(cardFile?.content).not.toContain("cn-font-heading")
|
||||
})
|
||||
|
||||
it("rewrites cn-font-heading to font-heading when a distinct heading font is selected", async () => {
|
||||
const payload = await buildV0Payload({
|
||||
...DEFAULT_CONFIG,
|
||||
item: undefined,
|
||||
fontHeading: "playfair-display",
|
||||
})
|
||||
|
||||
const cardFile = payload.files?.find(
|
||||
(file) => file.target === "components/ui/card.tsx"
|
||||
)
|
||||
|
||||
expect(cardFile?.content).toContain("font-heading")
|
||||
expect(cardFile?.content).not.toContain("cn-font-heading")
|
||||
})
|
||||
})
|
||||
617
apps/v4/app/(app)/(create)/lib/v0.ts
Normal file
617
apps/v4/app/(app)/(create)/lib/v0.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
import dedent from "dedent"
|
||||
import {
|
||||
registryItemFileSchema,
|
||||
registryItemSchema,
|
||||
type configSchema,
|
||||
type RegistryItem,
|
||||
} from "shadcn/schema"
|
||||
import {
|
||||
transformFont,
|
||||
transformIcons,
|
||||
transformMenu,
|
||||
transformRender,
|
||||
} from "shadcn/utils"
|
||||
import { Project, ScriptKind, type SourceFile } from "ts-morph"
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
buildRegistryBase,
|
||||
getBodyFont,
|
||||
getHeadingFont,
|
||||
getInheritedHeadingFontValue,
|
||||
type DesignSystemConfig,
|
||||
} from "@/registry/config"
|
||||
|
||||
const { Index } = await import("@/registry/bases/__index__")
|
||||
|
||||
function buildThemeInline(fontHeadingValue: string) {
|
||||
return `--font-sans: var(--font-sans);
|
||||
--font-heading: ${fontHeadingValue};
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);`
|
||||
}
|
||||
|
||||
// Static file — parsed once at module level.
|
||||
const themeProviderFile = registryItemFileSchema.parse({
|
||||
path: "components/theme-provider.tsx",
|
||||
type: "registry:component",
|
||||
target: "components/theme-provider.tsx",
|
||||
content: dedent`
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
|
||||
|
||||
function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
<ThemeHotkey />
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
target.isContentEditable ||
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT"
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeHotkey() {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || event.repeat) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() !== "d") {
|
||||
return
|
||||
}
|
||||
|
||||
if (isTypingTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [resolvedTheme, setTheme])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export { ThemeProvider }
|
||||
`,
|
||||
})
|
||||
|
||||
const ALIASES = {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
ui: "@/components/ui",
|
||||
lib: "@/lib",
|
||||
hooks: "@/hooks",
|
||||
} as const
|
||||
|
||||
type V0Transformer = (opts: {
|
||||
filename: string
|
||||
raw: string
|
||||
sourceFile: SourceFile
|
||||
config: z.infer<typeof configSchema>
|
||||
supportedFontMarkers?: string[]
|
||||
}) => Promise<unknown>
|
||||
|
||||
const transformers: V0Transformer[] = [
|
||||
transformIcons as V0Transformer,
|
||||
transformMenu as V0Transformer,
|
||||
transformRender as V0Transformer,
|
||||
transformFont as V0Transformer,
|
||||
]
|
||||
|
||||
function getStyle(designSystemConfig: DesignSystemConfig) {
|
||||
return `${designSystemConfig.base}-${designSystemConfig.style}`
|
||||
}
|
||||
|
||||
export async function buildV0Payload(designSystemConfig: DesignSystemConfig) {
|
||||
const registryBase = buildRegistryBase(designSystemConfig)
|
||||
const normalizedFontHeading =
|
||||
designSystemConfig.fontHeading === designSystemConfig.font
|
||||
? "inherit"
|
||||
: designSystemConfig.fontHeading
|
||||
|
||||
// Only buildComponentFiles is async — run sync builders directly.
|
||||
const globalsCss = buildGlobalsCss(
|
||||
registryBase,
|
||||
designSystemConfig.font,
|
||||
normalizedFontHeading
|
||||
)
|
||||
const layoutFile = buildLayoutFile(designSystemConfig, normalizedFontHeading)
|
||||
const componentFiles = await buildComponentFiles(designSystemConfig)
|
||||
|
||||
const dependencies = [...(registryBase.dependencies ?? []), "next-themes"]
|
||||
const componentsJson = buildComponentsJson(designSystemConfig)
|
||||
const packageJson = buildPackageJson(dependencies)
|
||||
|
||||
return registryItemSchema.parse({
|
||||
name: designSystemConfig.item ?? "open-in-v0",
|
||||
type: "registry:item",
|
||||
dependencies,
|
||||
files: [
|
||||
globalsCss,
|
||||
layoutFile,
|
||||
themeProviderFile,
|
||||
componentsJson,
|
||||
packageJson,
|
||||
...componentFiles,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function buildGlobalsCss(
|
||||
registryBase: RegistryItem,
|
||||
font: DesignSystemConfig["font"],
|
||||
fontHeading: DesignSystemConfig["fontHeading"]
|
||||
) {
|
||||
const lightVars = Object.entries(registryBase.cssVars?.light ?? {})
|
||||
.map(([key, value]) => ` --${key}: ${value};`)
|
||||
.join("\n")
|
||||
|
||||
const darkVars = Object.entries(registryBase.cssVars?.dark ?? {})
|
||||
.map(([key, value]) => ` --${key}: ${value};`)
|
||||
.join("\n")
|
||||
|
||||
const content = dedent`@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
${buildThemeInline(
|
||||
fontHeading === "inherit"
|
||||
? getInheritedHeadingFontValue(font)
|
||||
: "var(--font-heading)"
|
||||
)}
|
||||
}
|
||||
|
||||
:root {
|
||||
${lightVars}
|
||||
}
|
||||
|
||||
.dark {
|
||||
${darkVars}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
return registryItemFileSchema.parse({
|
||||
path: "app/globals.css",
|
||||
type: "registry:file",
|
||||
target: "app/globals.css",
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
function buildComponentsJson(designSystemConfig: DesignSystemConfig) {
|
||||
const content = JSON.stringify(
|
||||
{
|
||||
$schema: "https://ui.shadcn.com/schema.json",
|
||||
style: getStyle(designSystemConfig),
|
||||
rsc: true,
|
||||
tsx: true,
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "app/globals.css",
|
||||
baseColor: designSystemConfig.baseColor,
|
||||
cssVariables: true,
|
||||
prefix: "",
|
||||
},
|
||||
aliases: ALIASES,
|
||||
iconLibrary: designSystemConfig.iconLibrary,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
return registryItemFileSchema.parse({
|
||||
path: "components.json",
|
||||
type: "registry:file",
|
||||
target: "components.json",
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
function buildPackageJson(dependencies: string[]) {
|
||||
// Base package.json matching templates/next-app/package.json + component peer deps.
|
||||
const baseDependencies: Record<string, string> = {
|
||||
next: "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
react: "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
// Utility deps.
|
||||
"class-variance-authority": "^0.7.1",
|
||||
clsx: "^2.1.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
// Component peer deps.
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"input-otp": "1.4.1",
|
||||
"react-day-picker": "9.8.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
recharts: "2.15.4",
|
||||
sonner: "^1.7.4",
|
||||
vaul: "^1.1.2",
|
||||
"@vercel/analytics": "1.3.1",
|
||||
}
|
||||
|
||||
// Merge dynamic dependencies.
|
||||
for (const dep of dependencies) {
|
||||
const atIndex = dep.lastIndexOf("@")
|
||||
if (atIndex > 0) {
|
||||
// Has version: e.g. "shadcn@latest".
|
||||
baseDependencies[dep.slice(0, atIndex)] = dep.slice(atIndex + 1)
|
||||
} else {
|
||||
baseDependencies[dep] = "latest"
|
||||
}
|
||||
}
|
||||
|
||||
const content = JSON.stringify(
|
||||
{
|
||||
name: "next-app",
|
||||
version: "0.0.1",
|
||||
type: "module",
|
||||
private: true,
|
||||
scripts: {
|
||||
dev: "next dev --turbopack",
|
||||
build: "next build",
|
||||
start: "next start",
|
||||
lint: "eslint",
|
||||
format: 'prettier --write "**/*.{ts,tsx}"',
|
||||
typecheck: "tsc --noEmit",
|
||||
},
|
||||
dependencies: baseDependencies,
|
||||
devDependencies: {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
eslint: "^9.39.2",
|
||||
"eslint-config-next": "16.1.6",
|
||||
prettier: "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
postcss: "^8",
|
||||
tailwindcss: "^4.1.18",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
typescript: "^5.9.3",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
return registryItemFileSchema.parse({
|
||||
path: "package.json",
|
||||
type: "registry:file",
|
||||
target: "package.json",
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
function buildLayoutFile(
|
||||
designSystemConfig: DesignSystemConfig,
|
||||
fontHeading: DesignSystemConfig["fontHeading"]
|
||||
) {
|
||||
const font = getBodyFont(designSystemConfig.font)
|
||||
if (!font) {
|
||||
throw new Error(`Font "${designSystemConfig.font}" not found`)
|
||||
}
|
||||
|
||||
const headingFont =
|
||||
fontHeading === "inherit" ? undefined : getHeadingFont(fontHeading)
|
||||
|
||||
// Derive const name from the font's CSS variable (e.g. --font-sans → fontSans).
|
||||
const constName = font.font.variable
|
||||
.replace(/^--/, "")
|
||||
.replace(/-./g, (m) => m[1].toUpperCase())
|
||||
const headingConstName = "fontHeading"
|
||||
|
||||
// Add font-serif or font-mono class to body when needed. Sans is the default.
|
||||
const fontClass =
|
||||
font.font.variable === "--font-serif"
|
||||
? "font-serif"
|
||||
: font.font.variable === "--font-mono"
|
||||
? "font-mono"
|
||||
: ""
|
||||
|
||||
const bodyClassName = fontClass ? `antialiased ${fontClass}` : "antialiased"
|
||||
const imports = headingFont
|
||||
? Array.from(new Set([font.font.import, headingFont.font.import])).join(
|
||||
", "
|
||||
)
|
||||
: font.font.import
|
||||
const headingDeclaration = headingFont
|
||||
? `\nconst ${headingConstName} = ${headingFont.font.import}({subsets:['latin'],variable:'${headingFont.font.variable}'});\n`
|
||||
: ""
|
||||
const htmlClassName = headingFont
|
||||
? `{${constName}.variable + " " + ${headingConstName}.variable}`
|
||||
: `{${constName}.variable}`
|
||||
|
||||
const content = dedent`
|
||||
import type { Metadata } from "next";
|
||||
import { ${imports} } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
const ${constName} = ${font.font.import}({subsets:['latin'],variable:'${font.font.variable}'});
|
||||
${headingDeclaration}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning className=${htmlClassName}>
|
||||
<body
|
||||
className="${bodyClassName}"
|
||||
>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
`
|
||||
|
||||
return registryItemFileSchema.parse({
|
||||
path: "app/layout.tsx",
|
||||
type: "registry:page",
|
||||
target: "app/layout.tsx",
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
async function buildComponentFiles(designSystemConfig: DesignSystemConfig) {
|
||||
const allItemsForBase = Object.values(Index[designSystemConfig.base])
|
||||
.filter((item: RegistryItem) => item.type === "registry:ui")
|
||||
.map((item) => item.name)
|
||||
|
||||
// Build config once for all components.
|
||||
const config = buildTransformConfig(designSystemConfig)
|
||||
|
||||
// Fetch UI components, the demo, and the item component in parallel.
|
||||
const [registryItemFiles, demoFile, itemComponentFile] = await Promise.all([
|
||||
Promise.all(
|
||||
allItemsForBase.map((name) =>
|
||||
getRegistryItemFile(name, designSystemConfig, config)
|
||||
)
|
||||
),
|
||||
getRegistryItemFile("demo", designSystemConfig, config),
|
||||
designSystemConfig.item
|
||||
? getRegistryItemFile(designSystemConfig.item, designSystemConfig, config)
|
||||
: null,
|
||||
])
|
||||
|
||||
const files = [...registryItemFiles.filter(Boolean)]
|
||||
|
||||
// Include the demo component.
|
||||
if (demoFile) {
|
||||
files.push({
|
||||
...demoFile,
|
||||
target: "components/demo.tsx",
|
||||
type: "registry:component",
|
||||
})
|
||||
}
|
||||
|
||||
const pageFile = {
|
||||
path: "app/page.tsx",
|
||||
type: "registry:page",
|
||||
target: "app/page.tsx",
|
||||
content: dedent`
|
||||
import { Demo } from "@/components/demo"
|
||||
|
||||
export default function Page() {
|
||||
return <Demo />
|
||||
}
|
||||
`,
|
||||
}
|
||||
|
||||
// Build the actual item component.
|
||||
if (itemComponentFile) {
|
||||
// Find the export default function from the component file.
|
||||
const exportDefault = itemComponentFile.content.match(
|
||||
/export default function (\w+)/
|
||||
)
|
||||
if (exportDefault) {
|
||||
const functionName = exportDefault[1]
|
||||
|
||||
// Replace the export default function with a named export.
|
||||
itemComponentFile.content = itemComponentFile.content.replace(
|
||||
/export default function (\w+)/,
|
||||
`export function ${functionName}`
|
||||
)
|
||||
|
||||
// Import and render the item on the page.
|
||||
pageFile.content = dedent`import { ${functionName} } from "@/components/${designSystemConfig.item}";
|
||||
|
||||
export default function Page() {
|
||||
return <${functionName} />
|
||||
}`
|
||||
}
|
||||
|
||||
files.push({
|
||||
...itemComponentFile,
|
||||
target: `components/${designSystemConfig.item}.tsx`,
|
||||
type: "registry:component",
|
||||
})
|
||||
}
|
||||
|
||||
files.push(pageFile)
|
||||
|
||||
return z.array(registryItemFileSchema).parse(files)
|
||||
}
|
||||
|
||||
function buildTransformConfig(designSystemConfig: DesignSystemConfig) {
|
||||
return {
|
||||
$schema: "https://ui.shadcn.com/schema.json",
|
||||
style: getStyle(designSystemConfig),
|
||||
rsc: true,
|
||||
tsx: true,
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "app/globals.css",
|
||||
baseColor: designSystemConfig.baseColor,
|
||||
cssVariables: true,
|
||||
prefix: "",
|
||||
},
|
||||
iconLibrary: designSystemConfig.iconLibrary,
|
||||
aliases: ALIASES,
|
||||
menuAccent: designSystemConfig.menuAccent,
|
||||
menuColor: designSystemConfig.menuColor,
|
||||
resolvedPaths: {
|
||||
cwd: "/",
|
||||
tailwindConfig: "./tailwind.config.js",
|
||||
tailwindCss: "./globals.css",
|
||||
utils: "./lib/utils",
|
||||
components: "./components",
|
||||
lib: "./lib",
|
||||
hooks: "./hooks",
|
||||
ui: "./components/ui",
|
||||
},
|
||||
} satisfies z.infer<typeof configSchema>
|
||||
}
|
||||
|
||||
async function getRegistryItemFile(
|
||||
name: string,
|
||||
designSystemConfig: DesignSystemConfig,
|
||||
config: z.infer<typeof configSchema>
|
||||
) {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL}/r/styles/${getStyle(designSystemConfig)}/${name}.json`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch registry item: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
const item = registryItemSchema.parse(json)
|
||||
|
||||
const file = item.files?.[0]
|
||||
if (!file?.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = await transformFileContent(file.content, config)
|
||||
|
||||
return {
|
||||
...file,
|
||||
target:
|
||||
name === "example"
|
||||
? "components/example.tsx"
|
||||
: `components/ui/${name}.tsx`,
|
||||
type: name === "example" ? "registry:component" : "registry:ui",
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
async function transformFileContent(
|
||||
content: string,
|
||||
config: z.infer<typeof configSchema>
|
||||
) {
|
||||
const project = new Project({ compilerOptions: {} })
|
||||
const sourceFile = project.createSourceFile("component.tsx", content, {
|
||||
scriptKind: ScriptKind.TSX,
|
||||
overwrite: true,
|
||||
})
|
||||
|
||||
for (const transformer of transformers) {
|
||||
await transformer({
|
||||
filename: "component.tsx",
|
||||
raw: content,
|
||||
sourceFile,
|
||||
config,
|
||||
supportedFontMarkers: ["cn-font-heading"],
|
||||
})
|
||||
}
|
||||
|
||||
return sourceFile.getText()
|
||||
}
|
||||
94
apps/v4/app/(app)/(root)/cards/account-access.tsx
Normal file
94
apps/v4/app/(app)/(root)/cards/account-access.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ArrowRight01Icon,
|
||||
SquareLock02Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/styles/base-rhea/ui/card"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/styles/base-rhea/ui/field"
|
||||
import { Input } from "@/styles/base-rhea/ui/input"
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/styles/base-rhea/ui/item"
|
||||
|
||||
export function AccountAccess() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Access</CardTitle>
|
||||
<CardDescription>
|
||||
Update your credentials or re-authenticate.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email-address">Email Address</FieldLabel>
|
||||
<Input
|
||||
id="email-address"
|
||||
type="email"
|
||||
placeholder="artist@studio.inc"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel htmlFor="current-password">
|
||||
Current Password
|
||||
</FieldLabel>
|
||||
<a
|
||||
href="#"
|
||||
className="text-xs font-medium tracking-wider text-muted-foreground uppercase hover:text-foreground"
|
||||
>
|
||||
Forgot?
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
placeholder="••••••••••••••••••••••••"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-4">
|
||||
<Button className="w-full">
|
||||
<HugeiconsIcon icon={SquareLock02Icon} strokeWidth={2} />
|
||||
Update Security
|
||||
</Button>
|
||||
<Item variant="muted" render={<a href="#" />}>
|
||||
<ItemMedia variant="icon">
|
||||
<HugeiconsIcon
|
||||
icon={AlertCircleIcon}
|
||||
className="text-destructive"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Danger Zone</ItemTitle>
|
||||
<ItemDescription className="line-clamp-1">
|
||||
Archive account and remove catalog
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
className="size-4"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Item>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
46
apps/v4/app/(app)/(root)/cards/analytics-card.tsx
Normal file
46
apps/v4/app/(app)/(root)/cards/analytics-card.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Badge } from "@/styles/base-rhea/ui/badge"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/styles/base-rhea/ui/card"
|
||||
|
||||
const areaPath = "M0 52L18 40L36 46L54 70L72 50L100 49V86H0Z"
|
||||
const strokePath = "M0 52L18 40L36 46L54 70L72 50L100 49"
|
||||
|
||||
export function AnalyticsCard() {
|
||||
return (
|
||||
<Card className="mx-auto w-full max-w-sm data-[size=sm]:pb-0" size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Analytics</CardTitle>
|
||||
<CardDescription>
|
||||
418.2K Visitors <Badge>+10%</Badge>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button variant="outline" size="sm">
|
||||
View Analytics
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<svg
|
||||
viewBox="0 0 100 86"
|
||||
preserveAspectRatio="none"
|
||||
className="aspect-[1/0.35] w-full text-chart-1"
|
||||
role="img"
|
||||
aria-label="Visitor trend"
|
||||
>
|
||||
<path d={areaPath} fill="currentColor" opacity="0.28" />
|
||||
<path
|
||||
d={strokePath}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
75
apps/v4/app/(app)/(root)/cards/claimable-balance.tsx
Normal file
75
apps/v4/app/(app)/(root)/cards/claimable-balance.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Badge } from "@/styles/base-rhea/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/styles/base-rhea/ui/card"
|
||||
import { Item, ItemContent } from "@/styles/base-rhea/ui/item"
|
||||
import { Separator } from "@/styles/base-rhea/ui/separator"
|
||||
|
||||
const netRoyalties = 1248.75
|
||||
const processingFee = 37.46
|
||||
const totalClaimable = netRoyalties - processingFee
|
||||
|
||||
const formatCurrency = (amount: number) =>
|
||||
amount.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
|
||||
export function ClaimableBalance() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Claimable Balance</CardDescription>
|
||||
<CardTitle className="text-4xl tabular-nums">
|
||||
${formatCurrency(totalClaimable)}
|
||||
</CardTitle>
|
||||
<Badge variant="outline">
|
||||
<span className="size-2 rounded-full bg-yellow-500" />
|
||||
Pending Setup
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col justify-end">
|
||||
<Item variant="muted" className="flex-col items-stretch">
|
||||
<ItemContent className="gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Net Royalties
|
||||
</span>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
${formatCurrency(netRoyalties)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Processing Fee
|
||||
</span>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
-${formatCurrency(processingFee)}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Total Ready to Claim
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
${formatCurrency(totalClaimable)} USD
|
||||
</span>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<CardDescription>
|
||||
Once your bank is connected, balances over $10.00 are automatically
|
||||
eligible for monthly distribution on the 15th of each month.
|
||||
</CardDescription>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
88
apps/v4/app/(app)/(root)/cards/contribution-history.tsx
Normal file
88
apps/v4/app/(app)/(root)/cards/contribution-history.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Badge } from "@/styles/base-rhea/ui/badge"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/styles/base-rhea/ui/card"
|
||||
import { Item, ItemContent, ItemDescription } from "@/styles/base-rhea/ui/item"
|
||||
|
||||
const chartData = [
|
||||
{ month: "Dec", amount: 800 },
|
||||
{ month: "Jan", amount: 1100 },
|
||||
{ month: "Feb", amount: 900 },
|
||||
{ month: "Mar", amount: 1300 },
|
||||
{ month: "Apr", amount: 750 },
|
||||
]
|
||||
|
||||
export function ContributionHistory() {
|
||||
const maxAmount = Math.max(...chartData.map((item) => item.amount))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Contribution History</CardTitle>
|
||||
<CardDescription>Last 6 months of activity</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="flex h-[200px] w-full items-end gap-3"
|
||||
role="img"
|
||||
aria-label="Last 6 months of contribution activity"
|
||||
>
|
||||
{chartData.map((item, index) => (
|
||||
<div
|
||||
key={item.month}
|
||||
className="flex h-full flex-1 flex-col justify-end gap-2"
|
||||
>
|
||||
<div
|
||||
data-index={index}
|
||||
className="data-[index=5]:bg-chart-6 min-h-2 rounded-lg data-[index=0]:bg-chart-1 data-[index=1]:bg-chart-2 data-[index=2]:bg-chart-3 data-[index=3]:bg-chart-4 data-[index=4]:bg-chart-5"
|
||||
style={{ height: `${(item.amount / maxAmount) * 100}%` }}
|
||||
/>
|
||||
<span className="text-center text-xs text-muted-foreground">
|
||||
{item.month}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<Item variant="muted" className="flex-col items-stretch">
|
||||
<ItemContent className="gap-1">
|
||||
<ItemDescription className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
||||
Upcoming
|
||||
</ItemDescription>
|
||||
<span className="cn-font-heading text-base font-semibold">
|
||||
May 2024
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">Scheduled</span>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item
|
||||
variant="muted"
|
||||
className="hidden flex-col items-stretch xl:flex"
|
||||
>
|
||||
<ItemContent className="gap-1">
|
||||
<ItemDescription className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
||||
Savings Plan
|
||||
</ItemDescription>
|
||||
<span className="cn-font-heading text-base font-semibold">
|
||||
Accelerated
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">Recurring</span>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full">View Full Report</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user