Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fb88e9349 | ||
|
|
2f5b17b13e | ||
|
|
676283cce9 | ||
|
|
6165f2d4f3 | ||
|
|
ba41125eae | ||
|
|
ed57dc2d4f | ||
|
|
033fb87269 | ||
|
|
4fb26f3996 | ||
|
|
1a6b360701 | ||
|
|
24ccc179a6 | ||
|
|
51ffafeeda | ||
|
|
3f43d10ae4 | ||
|
|
b82f69783d | ||
|
|
e97e281bda | ||
|
|
00cebb7bb5 | ||
|
|
942d71040b | ||
|
|
92d21e7fc0 | ||
|
|
07e4705a6a | ||
|
|
6320296f0f | ||
|
|
f4b14e6c01 | ||
|
|
7e22831d08 | ||
|
|
9332fc9254 | ||
|
|
018c949104 | ||
|
|
b55976508e | ||
|
|
264dcb3053 | ||
|
|
22c33c0f7a | ||
|
|
57d420a9f4 | ||
|
|
f8cc08ba3e | ||
|
|
60d5c4cb1f | ||
|
|
0288e2f4de | ||
|
|
33d5a79759 | ||
|
|
30b89a3cf9 | ||
|
|
427dc2d040 | ||
|
|
033f32d596 | ||
|
|
d5fa42b55d | ||
|
|
4bb35b1e50 | ||
|
|
d451857d7b | ||
|
|
78b804176a | ||
|
|
15b780ab62 | ||
|
|
9448c4e3d6 | ||
|
|
4af9759700 | ||
|
|
835cbbe937 | ||
|
|
491bb9ba65 | ||
|
|
ced29535f9 | ||
|
|
d99e9a105b | ||
|
|
c72c7c5c34 | ||
|
|
23960bc30d | ||
|
|
abb48c281b | ||
|
|
7acb825713 | ||
|
|
dd2f329485 | ||
|
|
75577ec0bd | ||
|
|
7366bcda71 | ||
|
|
23e43cfad3 | ||
|
|
ad9a8085aa | ||
|
|
2f23e8f23d | ||
|
|
4c07b8a236 | ||
|
|
571bd586fa | ||
|
|
eac2d88873 | ||
|
|
93c793f393 | ||
|
|
1aa97b63f9 | ||
|
|
c1b76a2b84 | ||
|
|
a1429aa723 | ||
|
|
2de7c7b19c | ||
|
|
7383bd2c48 | ||
|
|
a05d65099b | ||
|
|
c6db929d12 | ||
|
|
dc88f6c51a | ||
|
|
d69fb0e26d | ||
|
|
4b2d53d3f4 | ||
|
|
e3fa8b6d72 | ||
|
|
5c6a9f7306 | ||
|
|
71e33dbf66 | ||
|
|
d468ab1575 | ||
|
|
74482af471 | ||
|
|
0b8b092cb9 | ||
|
|
3cdfaef942 | ||
|
|
fcf441a190 | ||
|
|
5a1c4ad99c | ||
|
|
d9cb72f22e | ||
|
|
58eb37124e | ||
|
|
afeeda3a8d | ||
|
|
fece90d2bd | ||
|
|
357a85fa3d | ||
|
|
f34158caa6 | ||
|
|
1bb343e49a | ||
|
|
dffa5d4161 | ||
|
|
13590b08a8 | ||
|
|
6e14ce209d | ||
|
|
72b8c2247e | ||
|
|
925504a928 | ||
|
|
236d67707f | ||
|
|
753f432c95 | ||
|
|
c824bc51b2 | ||
|
|
39ee540b55 | ||
|
|
903b214051 | ||
|
|
ac0daf5fe7 | ||
|
|
80e38a07e9 | ||
|
|
d2598e09f7 | ||
|
|
28aab47150 | ||
|
|
81bda67ae4 | ||
|
|
25bbd1ff85 | ||
|
|
164d695a5f | ||
|
|
57e4a3e77c | ||
|
|
a6ac532b2b | ||
|
|
4f14722e14 | ||
|
|
9a6eece7a1 | ||
|
|
fd65d0a9a7 | ||
|
|
48500fe8a4 | ||
|
|
8d8d9e4bee | ||
|
|
dcbddf9f23 | ||
|
|
7449c0a61a | ||
|
|
c95b8ccb32 | ||
|
|
2c5bfe8e33 | ||
|
|
a3bbd97333 | ||
|
|
4ea488951d | ||
|
|
481424d5a6 | ||
|
|
fe82e96cc1 | ||
|
|
976258c959 | ||
|
|
83cf0689e2 | ||
|
|
c911cb4423 | ||
|
|
4eadef8b65 | ||
|
|
4e081358ff | ||
|
|
209ec0b435 | ||
|
|
6d363abb54 | ||
|
|
6f8b84aeda | ||
|
|
5b8c5da33a | ||
|
|
b6ef0f95cb | ||
|
|
cce1480a42 | ||
|
|
44fbe9f2d6 | ||
|
|
29db6c7d67 | ||
|
|
26e9b56961 | ||
|
|
979271cfb5 | ||
|
|
69f0b00ed3 | ||
|
|
c1a1839457 | ||
|
|
dd4b53800e | ||
|
|
1fc94528fc | ||
|
|
bb1a817980 | ||
|
|
53305acbc7 | ||
|
|
d0f1bae0bd | ||
|
|
c0cf3b1bb7 | ||
|
|
ce67b668d5 | ||
|
|
3273cfd0e1 | ||
|
|
fd992a6514 | ||
|
|
550d681fe1 | ||
|
|
7d29226953 | ||
|
|
38da72ca91 | ||
|
|
eab11361cb | ||
|
|
b281bba36f | ||
|
|
5e38573aed | ||
|
|
da7b6d487c | ||
|
|
6bf4e5f4bb | ||
|
|
213f532a3c | ||
|
|
45bd88e4d2 | ||
|
|
4eb8658459 | ||
|
|
b37118fee2 | ||
|
|
026caba68a | ||
|
|
2003f8801a | ||
|
|
473bacf13a | ||
|
|
8b3f2bc61a | ||
|
|
3aa373c33c | ||
|
|
9b6263e583 | ||
|
|
3e066d08ce | ||
|
|
aa48e28162 | ||
|
|
dc660ed94a | ||
|
|
e02ecf9553 | ||
|
|
23acc0926f | ||
|
|
3fc1cd18d5 | ||
|
|
ebe29b403e | ||
|
|
dcba3312dd | ||
|
|
7fddeeec37 | ||
|
|
b5341b023f | ||
|
|
bbb7443ef2 | ||
|
|
22df347bb9 | ||
|
|
9aed6b8f35 | ||
|
|
9e7ec9f1bd | ||
|
|
373f43eac5 | ||
|
|
6c91b2791a | ||
|
|
bde62a431f | ||
|
|
615a074540 | ||
|
|
09b335ac46 | ||
|
|
2701880c77 | ||
|
|
24c8f2fb38 | ||
|
|
c3ec158ac3 | ||
|
|
b3690222d0 | ||
|
|
4d7583785b | ||
|
|
17ad1a15db | ||
|
|
4c89de9e76 | ||
|
|
5f25af41f9 | ||
|
|
3a3ee357fc | ||
|
|
08a6d27903 | ||
|
|
33f7675aac | ||
|
|
1178a0a611 | ||
|
|
96ded77bec | ||
|
|
e83ca8cb2b | ||
|
|
e507176d39 | ||
|
|
4e554b8bee | ||
|
|
bc649caa99 | ||
|
|
f7a4c7b616 | ||
|
|
2c7be5882c | ||
|
|
6107d268f7 | ||
|
|
7760699155 | ||
|
|
acf349c72e | ||
|
|
8096e5b882 | ||
|
|
873cfc7dc2 | ||
|
|
038174bc7e | ||
|
|
14881096bb | ||
|
|
ed1905d8f4 | ||
|
|
72c0a2efa3 | ||
|
|
e599d48713 | ||
|
|
7f44634d4f | ||
|
|
44170caac6 | ||
|
|
adedfadd92 | ||
|
|
6b01d0c8fe | ||
|
|
d4d96f59ab | ||
|
|
a1bf7ed6bd | ||
|
|
306e20efc9 | ||
|
|
f593f6e0a8 | ||
|
|
74646a4318 | ||
|
|
8bfcba8832 | ||
|
|
4ba5621a4d | ||
|
|
1f928324c2 | ||
|
|
3771872cd2 | ||
|
|
f359a60343 | ||
|
|
f53afd7bc9 | ||
|
|
e7dcc2ab29 | ||
|
|
0118b9e591 | ||
|
|
ffe42f9777 | ||
|
|
5a467599e9 | ||
|
|
c56e78484a | ||
|
|
1ee0d4a09e | ||
|
|
ffdce5820b | ||
|
|
9fed504f37 | ||
|
|
6c5799be0d | ||
|
|
b6747a0340 | ||
|
|
cd6df02e30 | ||
|
|
edbef6a36f | ||
|
|
b6a0016f5f | ||
|
|
e740c17837 | ||
|
|
7eb949f109 | ||
|
|
02394e310d | ||
|
|
ef83832819 | ||
|
|
a82101ab30 | ||
|
|
007c78930e | ||
|
|
300c83512c | ||
|
|
b674872c56 | ||
|
|
12a7680275 | ||
|
|
c9cf7c942b | ||
|
|
8e0326c8f8 | ||
|
|
13972119a8 | ||
|
|
acf165a119 | ||
|
|
bdff7c6069 | ||
|
|
e2e3376c87 | ||
|
|
3c613088e5 | ||
|
|
7e9ece50a0 | ||
|
|
af81b6536a | ||
|
|
4cd3ecf81f | ||
|
|
31f6b9e08e | ||
|
|
02de920cb3 | ||
|
|
84c0905297 | ||
|
|
250583f859 | ||
|
|
ee7d42196d | ||
|
|
a397de2b9f | ||
|
|
0610df9400 | ||
|
|
ed583deb31 | ||
|
|
e039048fc6 | ||
|
|
f2ea9650c4 | ||
|
|
1e89057006 | ||
|
|
019bb4842f | ||
|
|
4cb45beded | ||
|
|
8a2059f087 | ||
|
|
a6b105a2e1 | ||
|
|
6c14a6a06c | ||
|
|
cddd5d0026 | ||
|
|
78dea91e1d | ||
|
|
3df591ec52 | ||
|
|
1c4032caa6 | ||
|
|
465c422171 | ||
|
|
284e8dc6f5 | ||
|
|
3fc905c82e | ||
|
|
55a42b88ae | ||
|
|
bd26162e4a | ||
|
|
62e7e33406 | ||
|
|
2f0ddcec3f | ||
|
|
7cdfa68e0e | ||
|
|
f8939a964d | ||
|
|
7830e5dda5 | ||
|
|
6aa2f22294 | ||
|
|
50b14e3d44 | ||
|
|
e09c2192c2 | ||
|
|
b725d2cc62 | ||
|
|
abf3f617a4 | ||
|
|
1eda7e66fe | ||
|
|
b5000531c4 | ||
|
|
d4f95e752b | ||
|
|
e31c611f70 | ||
|
|
085481bee6 | ||
|
|
cf610fbe49 | ||
|
|
b681eb3cd2 | ||
|
|
f8ffdf0e81 | ||
|
|
ba38bce1da | ||
|
|
3a39b05fe4 | ||
|
|
27d2982b28 | ||
|
|
33e4d97be5 | ||
|
|
654064d7c5 | ||
|
|
aeb0da9e0d | ||
|
|
cb1a2fe067 | ||
|
|
85667ddfad | ||
|
|
7ad5016966 | ||
|
|
45c3315fd0 | ||
|
|
c5f550dd00 | ||
|
|
37c870c7ab | ||
|
|
155b768f73 | ||
|
|
09f0873f27 | ||
|
|
19a12f6c8f | ||
|
|
79a498d1d6 | ||
|
|
12a03c36f3 | ||
|
|
ddc8bcab9b | ||
|
|
35dacfd48a | ||
|
|
611cef34ab | ||
|
|
8a8f9fe890 | ||
|
|
5a65b70c80 | ||
|
|
8bea3faa1f | ||
|
|
4125954d53 | ||
|
|
3d9c5a729e | ||
|
|
51d775ea6d | ||
|
|
e461c47c4b | ||
|
|
bbdb38e45a | ||
|
|
083e047aec | ||
|
|
84e0b504ab | ||
|
|
bedff6832c | ||
|
|
6c29e04de2 | ||
|
|
ba674323e1 | ||
|
|
6d756a161e | ||
|
|
66a5cd95ec | ||
|
|
9f70648264 | ||
|
|
b975b79691 | ||
|
|
3cab896954 | ||
|
|
b9680e5b35 | ||
|
|
43a003ca7d | ||
|
|
fbe1cf2009 | ||
|
|
fdee61cc11 | ||
|
|
8b51200d9c | ||
|
|
0631ab536a | ||
|
|
0d76e76519 | ||
|
|
9e9f83d211 | ||
|
|
0829d477d9 | ||
|
|
1b7109fa23 | ||
|
|
366669ccd8 | ||
|
|
e05edd89fa | ||
|
|
a405df66c9 | ||
|
|
28a57ead9f | ||
|
|
72bfacae75 | ||
|
|
10f8821b3b | ||
|
|
92a3745ce1 | ||
|
|
aff7ff1451 | ||
|
|
9e63e0ff09 | ||
|
|
2c5ebd8620 | ||
|
|
e6d63db3a9 | ||
|
|
2cc316aafb | ||
|
|
4f25cfa0dd | ||
|
|
0385de3882 | ||
|
|
2e1253c54e | ||
|
|
14df15caeb | ||
|
|
af481a2ec8 | ||
|
|
96c1d80844 | ||
|
|
57ea2e25bd | ||
|
|
cd17b0a6d7 | ||
|
|
c5dd8823f6 | ||
|
|
c27f16f753 | ||
|
|
4842610229 | ||
|
|
bd33669dba | ||
|
|
e81684d322 | ||
|
|
8209dfa26c | ||
|
|
0a82c62a4e | ||
|
|
4399dfe6b8 | ||
|
|
defd0cc78c | ||
|
|
fd5fc8294c | ||
|
|
10823b416c | ||
|
|
4490920ac6 | ||
|
|
e0b8ad4a32 | ||
|
|
e11063832b | ||
|
|
d598fdd753 | ||
|
|
157b93239a | ||
|
|
3d7058dccd | ||
|
|
1ec654d1ea | ||
|
|
8ce2d4269d | ||
|
|
49e8aa7869 | ||
|
|
0827f55d43 | ||
|
|
ba6ff8c342 | ||
|
|
c0b7dc2737 | ||
|
|
ab6abd453b | ||
|
|
77921aca75 | ||
|
|
a8f91c4814 | ||
|
|
7780fa792c | ||
|
|
41e4c376ec | ||
|
|
09aad61cfe | ||
|
|
31ac81428f | ||
|
|
60daab82d2 | ||
|
|
e7a0544004 | ||
|
|
ff0364bba8 | ||
|
|
a3b64be351 | ||
|
|
eae370e346 | ||
|
|
0d86b50a83 | ||
|
|
543b9028a3 | ||
|
|
867f12d11f | ||
|
|
462781ea6a | ||
|
|
c300ab6459 | ||
|
|
448d8f3007 | ||
|
|
faa53b0af1 | ||
|
|
8e53b8d9a6 | ||
|
|
5678567baa | ||
|
|
1c30f73bb1 | ||
|
|
bd3dbc3033 | ||
|
|
3c312ab8d3 | ||
|
|
7284785db0 | ||
|
|
4e543ea490 | ||
|
|
4d3540126f | ||
|
|
6c29f0d792 | ||
|
|
c85df7c40f | ||
|
|
4f6ff575ce | ||
|
|
aaf3890f71 | ||
|
|
4f9837d991 | ||
|
|
2e2eb008c6 | ||
|
|
fa7074bb59 | ||
|
|
18a7def050 | ||
|
|
40e9cf5fe5 | ||
|
|
6ce049a57e | ||
|
|
d4c4eac710 | ||
|
|
28ec96e5e8 | ||
|
|
a20fe8eb06 | ||
|
|
7d1d9b881d | ||
|
|
c116e6cbb0 | ||
|
|
a3ebf0a33b | ||
|
|
ea6ef022e7 | ||
|
|
683e29196a | ||
|
|
71694d7082 | ||
|
|
c5d5656fb6 | ||
|
|
b1fdbee5f8 | ||
|
|
70405a1cd0 | ||
|
|
3d7e81169f | ||
|
|
fa7c8c7219 | ||
|
|
358a25f522 | ||
|
|
690169a409 | ||
|
|
0ec4cc5a51 | ||
|
|
0b7dbaf368 | ||
|
|
940c445182 | ||
|
|
d8a90c9aa5 | ||
|
|
89ee8fceca | ||
|
|
68b55a2d33 | ||
|
|
8cc2e7747f | ||
|
|
4e6ac3a294 | ||
|
|
732e22ea7b | ||
|
|
ac0d9981f8 | ||
|
|
23f7310760 | ||
|
|
02a9ce6bac | ||
|
|
069665358f | ||
|
|
2cf6d69127 | ||
|
|
870435061d | ||
|
|
53ad906491 | ||
|
|
01399e343b | ||
|
|
a0bdec4c42 | ||
|
|
914f2057d8 | ||
|
|
ed9b9dca79 | ||
|
|
f6d4383ff6 | ||
|
|
b6ccf30ae4 | ||
|
|
48e44a0d38 | ||
|
|
735998b48c | ||
|
|
d94eefddb7 | ||
|
|
5a65b4df17 | ||
|
|
15a839a310 | ||
|
|
9730c61962 | ||
|
|
1805c041a6 | ||
|
|
f0bde93d5f | ||
|
|
1ed7412f92 | ||
|
|
1f5ea2d25b | ||
|
|
ff4c8f0e85 | ||
|
|
5c3dd878b8 | ||
|
|
20f0d91c75 | ||
|
|
894e48756f | ||
|
|
aef199b292 | ||
|
|
9b884cd941 | ||
|
|
b22d813cee | ||
|
|
0981566cbe | ||
|
|
0647aa435a | ||
|
|
58179f98e9 | ||
|
|
05fe62536b | ||
|
|
241d41e220 | ||
|
|
34d6134c24 | ||
|
|
702d1d1998 | ||
|
|
e43a5fd5a3 | ||
|
|
4ed9a7b7f5 | ||
|
|
d8ba76e395 | ||
|
|
448004abca | ||
|
|
7b0db34fb1 | ||
|
|
a7e7d5b372 | ||
|
|
9768628e8b | ||
|
|
6c0328b2fd | ||
|
|
6bf7d879e2 | ||
|
|
f25490f1ad | ||
|
|
31eee78535 | ||
|
|
4ed2aff1b7 | ||
|
|
6f05545be6 | ||
|
|
dfec8a0e4f | ||
|
|
47b730111b | ||
|
|
59ba27c36f | ||
|
|
b501164c38 | ||
|
|
1b795087bf | ||
|
|
7063edc10f | ||
|
|
c02abcb760 | ||
|
|
2d1a20dcc6 | ||
|
|
65156d4b20 | ||
|
|
951cedb190 | ||
|
|
11442f320d | ||
|
|
33df21f656 | ||
|
|
19ab208678 | ||
|
|
59b9651575 | ||
|
|
c1c1f1767a | ||
|
|
dda8a5180c | ||
|
|
34d558412f | ||
|
|
be77ee1903 | ||
|
|
fc571d6c73 | ||
|
|
7ca5a97535 | ||
|
|
730239ba8d | ||
|
|
9069e23188 | ||
|
|
366203fe21 | ||
|
|
f23f15c972 | ||
|
|
8432290a70 | ||
|
|
ceddd503f8 | ||
|
|
51ad35f72b | ||
|
|
5631b24a14 | ||
|
|
f9da25643d | ||
|
|
75bed3f5dd | ||
|
|
d4576dfc21 | ||
|
|
31bad3c2e5 | ||
|
|
a3c0e81e04 | ||
|
|
b5cf721efb | ||
|
|
95063398aa | ||
|
|
2da25c5566 | ||
|
|
a72c1ab380 | ||
|
|
87b9301e20 | ||
|
|
d0a6bed727 | ||
|
|
29881fbf28 | ||
|
|
4f05ad6546 | ||
|
|
36fc9aae6c | ||
|
|
fb3f87c685 | ||
|
|
a4c0eeb9b3 | ||
|
|
c64f249fd0 | ||
|
|
48b542293f | ||
|
|
95e92970c9 | ||
|
|
afdb16da07 | ||
|
|
0a230cfab9 | ||
|
|
c752a07ad0 | ||
|
|
cc5a6b7317 | ||
|
|
d93991ad86 | ||
|
|
5c98376a8c | ||
|
|
28699f0ff0 | ||
|
|
e3576b17b1 | ||
|
|
17c49b2b03 | ||
|
|
dcc00d8d1f | ||
|
|
05a772eee2 | ||
|
|
59dd291dc6 | ||
|
|
f0a791e354 | ||
|
|
94db6cff7b | ||
|
|
72a02bf6d2 | ||
|
|
cf99209887 | ||
|
|
ff7ed49905 | ||
|
|
ecd17a51df | ||
|
|
63b08f8c12 | ||
|
|
3077772aff | ||
|
|
f40aa9ed14 | ||
|
|
4ae6dc3a69 | ||
|
|
bdb8389d8d | ||
|
|
f7254c7ec7 | ||
|
|
3701156362 | ||
|
|
575d25f5ad | ||
|
|
698b82c7d3 | ||
|
|
eaa9da6d2e | ||
|
|
ca7294a0e3 | ||
|
|
5281ffce60 | ||
|
|
2f84fd3b33 | ||
|
|
bef270da74 | ||
|
|
bdf839ffca | ||
|
|
b1b2ead0d8 | ||
|
|
0b74473c64 | ||
|
|
2fb189cf5c | ||
|
|
24a29639e9 | ||
|
|
50258dc359 | ||
|
|
d9c6b7698f | ||
|
|
f042f34ae2 | ||
|
|
c087edc14f | ||
|
|
d96ccc3265 | ||
|
|
018ef19efd | ||
|
|
159317a15a | ||
|
|
34583c96bf | ||
|
|
887d3d3ab4 | ||
|
|
f2de39de33 | ||
|
|
8173c31a60 | ||
|
|
80a2871741 | ||
|
|
3b37e9c420 | ||
|
|
730ba561bc | ||
|
|
4d7ddc9d6f | ||
|
|
680f71a602 | ||
|
|
170a7b50b9 | ||
|
|
805c48c1bb | ||
|
|
7d92ed51bb | ||
|
|
f849897f29 | ||
|
|
75e0f1ad1e | ||
|
|
2f015aff2b | ||
|
|
bb322adb2f | ||
|
|
6ee0981885 | ||
|
|
877fb0f201 | ||
|
|
ded4b9094a | ||
|
|
36ab931998 | ||
|
|
20baddfcca | ||
|
|
d180c147db | ||
|
|
86fa91cc52 | ||
|
|
b8c12a0307 | ||
|
|
eff2f9c195 | ||
|
|
8a8597afef | ||
|
|
067e619488 | ||
|
|
f7f8bc8c55 | ||
|
|
0c71fd7e59 | ||
|
|
5b2bd21c06 | ||
|
|
9ac68f68e2 | ||
|
|
aa32f00b0d | ||
|
|
bd5f5dffad | ||
|
|
645ad63f4f | ||
|
|
c83c6afecc | ||
|
|
689a191d4c | ||
|
|
5767ba0de5 | ||
|
|
cdf78f2598 | ||
|
|
d23f46ad9e | ||
|
|
8453fa5007 | ||
|
|
0bd990a9e3 | ||
|
|
9c8ee1471b | ||
|
|
87262e82a3 | ||
|
|
953db88ed2 | ||
|
|
a44bb949eb | ||
|
|
9d11369059 | ||
|
|
8c92f39349 | ||
|
|
bce9d733d3 | ||
|
|
525c216401 | ||
|
|
c145e9f11d | ||
|
|
53700003f1 | ||
|
|
03094a4c35 | ||
|
|
934ebed24f | ||
|
|
9f469e1637 | ||
|
|
77fe39774d | ||
|
|
186d688ec8 | ||
|
|
de3181b5c7 | ||
|
|
6d53234f7a | ||
|
|
b0d3673f4b | ||
|
|
e40083f585 | ||
|
|
f7b702fa29 | ||
|
|
5089a19a56 | ||
|
|
d77e9842aa | ||
|
|
88ae76a25a | ||
|
|
9b0b0d10a1 | ||
|
|
68340a8b02 | ||
|
|
d14f0966d5 | ||
|
|
e4b83f28c4 | ||
|
|
1925deec90 | ||
|
|
e9ba960b29 | ||
|
|
821f0f5b60 | ||
|
|
8fa8915673 | ||
|
|
5405aa469e | ||
|
|
cfa50b4e8c | ||
|
|
c5014baeb4 | ||
|
|
c14f120ec2 | ||
|
|
b72fcf39da | ||
|
|
6da3b0cba0 | ||
|
|
752387a02d | ||
|
|
32d3e1b957 | ||
|
|
a029dff202 | ||
|
|
89e1b85a56 | ||
|
|
4350eb709c | ||
|
|
2b1b6e916e | ||
|
|
3f6f9045bd | ||
|
|
8294496faa | ||
|
|
9f58118ecd | ||
|
|
3ba9d42587 | ||
|
|
e496a5f3d0 | ||
|
|
04e2bb29b0 | ||
|
|
131a7a015c | ||
|
|
6f430f5f14 | ||
|
|
0154c106ef | ||
|
|
04de6e6fc0 | ||
|
|
2b17e5f842 | ||
|
|
0dc8c1e68a | ||
|
|
67b291eb09 | ||
|
|
d7865a5a4e | ||
|
|
d1809587a8 | ||
|
|
64ec4dfd53 | ||
|
|
6e07d21a3d | ||
|
|
018e47bd53 | ||
|
|
065a2e9347 | ||
|
|
b53307c7fd | ||
|
|
e4cc0d960e | ||
|
|
3336824df7 | ||
|
|
166173d53c | ||
|
|
45da2d7924 | ||
|
|
af14a65ef5 | ||
|
|
9049a4b9ad | ||
|
|
2f4873ab9a | ||
|
|
0fb25d71dc | ||
|
|
34b6901434 | ||
|
|
a90b43aba3 | ||
|
|
0ac5c2c300 | ||
|
|
6290fee7bd | ||
|
|
320992f3fa | ||
|
|
bd714f0c93 | ||
|
|
45d3867c1f | ||
|
|
deafa66565 | ||
|
|
ce8fcdaa7a | ||
|
|
b83965c144 | ||
|
|
2505a8cab8 | ||
|
|
21e598bb31 | ||
|
|
ee2f3c0dfc | ||
|
|
197bda14b8 | ||
|
|
d416c2e421 | ||
|
|
6bc2e669e4 | ||
|
|
9accb65256 | ||
|
|
d9212584ca | ||
|
|
1a7effb422 | ||
|
|
1480435f15 | ||
|
|
64c6a97657 | ||
|
|
7ae2ff9837 | ||
|
|
f19dec405d | ||
|
|
bbd4e9d162 | ||
|
|
58deff0869 | ||
|
|
628632f43e | ||
|
|
436586117c | ||
|
|
7234ec728c | ||
|
|
fc4d68be39 | ||
|
|
bcf2df4bff | ||
|
|
70a469a951 | ||
|
|
981995cb32 | ||
|
|
406c7d368b | ||
|
|
5c266d8b8d | ||
|
|
4f5b94a491 | ||
|
|
284c83cc1d | ||
|
|
23f06af202 | ||
|
|
82be317595 | ||
|
|
a43c76529f | ||
|
|
26065c2a5c | ||
|
|
6b1dfe01cf | ||
|
|
72443fb7c6 | ||
|
|
28cad23746 | ||
|
|
ebc0d4b137 | ||
|
|
4b198acda7 | ||
|
|
b84c3c75a5 | ||
|
|
de0ec97d9b | ||
|
|
a8302cd276 | ||
|
|
35d8d77f15 | ||
|
|
f853394d9d | ||
|
|
500c01dd1f | ||
|
|
71bfccfb0c | ||
|
|
71e1b64818 | ||
|
|
2118a00a36 | ||
|
|
1c69c21f21 | ||
|
|
31c76fc1c0 | ||
|
|
d31cdf8af2 | ||
|
|
29b5fd2c6f | ||
|
|
aa32525f8c | ||
|
|
f6a67a3373 | ||
|
|
4f78858641 | ||
|
|
c7e70bdaf8 | ||
|
|
5479e0957b | ||
|
|
8500554cfb | ||
|
|
368727cece | ||
|
|
742faf3805 | ||
|
|
7d08cbed84 | ||
|
|
f413bb5f3c | ||
|
|
2b3edc7b45 | ||
|
|
83f982f8d2 | ||
|
|
890a1d41b1 | ||
|
|
996a2c26ca | ||
|
|
fadf042101 | ||
|
|
a819078d7f | ||
|
|
2c9b4c663d | ||
|
|
22e6803e14 | ||
|
|
61cecb112f | ||
|
|
0c18aff34d | ||
|
|
599649a97f | ||
|
|
45194b82da | ||
|
|
570d0985d3 | ||
|
|
5eae625b28 | ||
|
|
fcc5460ff2 | ||
|
|
07d45e50de | ||
|
|
73ea5708e3 | ||
|
|
5595f80e27 | ||
|
|
48a54a9a65 | ||
|
|
78cc348639 | ||
|
|
e7ca70b676 | ||
|
|
59332e3a1c | ||
|
|
a6f2491ebc | ||
|
|
9d07fddc65 | ||
|
|
eba7331a95 | ||
|
|
ec215a62ae | ||
|
|
23797115b7 | ||
|
|
304b83b5bd | ||
|
|
8c5f6d27fe | ||
|
|
350630a408 | ||
|
|
242db5cb88 | ||
|
|
60d856f0a9 | ||
|
|
9d6b75b37a | ||
|
|
01b3e96f74 | ||
|
|
4f1f4b691a | ||
|
|
1a0bfec6ef | ||
|
|
07ad994efc | ||
|
|
225b48c595 | ||
|
|
5e5735bf25 |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
Configs/supportbot.yml filter=supportbot-token
|
||||
Configs/api.yml filter=api-secrets
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dashboard/node_modules
|
||||
|
||||
# Local git hooks + secret-stripping scripts (run npm run git:setup)
|
||||
scripts/
|
||||
|
||||
# Local secrets — use *.example as templates
|
||||
Configs/api.yml
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Emerald Services
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,2 +0,0 @@
|
||||
# SupportBot-Dashboard
|
||||
Source code for the SupportBot Dashboard
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SupportBot Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4081
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "supportbot-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hugeicons/core-free-icons": "^4.1.4",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.0",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"emoji-picker-element": "^1.26.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"postcss": "^8.4.49",
|
||||
"react": "^19.0.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,147 @@
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import Layout from "./components/Layout";
|
||||
import { useAuth } from "./context/AuthContext";
|
||||
import { useSetup } from "./context/SetupContext";
|
||||
import Configs from "./pages/Configs";
|
||||
import Login from "./pages/Login";
|
||||
import Setup from "./pages/Setup";
|
||||
import { HomeRedirect } from "./components/home-redirect";
|
||||
import { RequirePermission } from "./components/require-permission";
|
||||
import Addons from "./pages/Addons";
|
||||
import { AddonBuilder } from "@/pages/AddonBuilder";
|
||||
import { DashboardSettings } from "@/pages/DashboardSettings";
|
||||
import Settings from "./pages/Settings";
|
||||
import SystemDetails from "./pages/SystemDetails";
|
||||
import Logs from "./pages/Logs";
|
||||
import Transcripts from "./pages/Transcripts";
|
||||
import Tickets from "./pages/Tickets";
|
||||
import Users from "./pages/Users";
|
||||
import { Groups } from "./pages/Groups";
|
||||
|
||||
function Protected({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { complete, loading } = useSetup();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!complete) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
if (location.pathname === "/setup") {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<Protected>
|
||||
<Layout />
|
||||
</Protected>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomeRedirect />} />
|
||||
<Route
|
||||
path="system"
|
||||
element={
|
||||
<RequirePermission permission="overview">
|
||||
<SystemDetails />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings"
|
||||
element={
|
||||
<RequirePermission permission="settings.view">
|
||||
<Settings />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="configs" element={<Configs />} />
|
||||
<Route
|
||||
path="logs"
|
||||
element={
|
||||
<RequirePermission permission="logs">
|
||||
<Logs />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="tickets"
|
||||
element={
|
||||
<RequirePermission permission="transcripts">
|
||||
<Tickets />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="transcripts"
|
||||
element={
|
||||
<RequirePermission permission="transcripts">
|
||||
<Transcripts />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
element={
|
||||
<RequirePermission permission="users.view">
|
||||
<Users />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="groups"
|
||||
element={
|
||||
<RequirePermission permission="users.view">
|
||||
<Groups />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="addons"
|
||||
element={
|
||||
<RequirePermission permission="settings.view">
|
||||
<Addons />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="addons/builder"
|
||||
element={
|
||||
<RequirePermission permission="settings.update">
|
||||
<AddonBuilder />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import type {
|
||||
DashboardGroup,
|
||||
DashboardPermissions,
|
||||
DashboardRole,
|
||||
DashboardUser,
|
||||
StoredDashboardUser,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
export type { DashboardUser, DashboardPermissions, StoredDashboardUser, DashboardRole, DashboardGroup };
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 25_000;
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
if (!headers.has("Content-Type") && options.body) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const impersonatedGroup = typeof window !== "undefined" ? sessionStorage.getItem("nexus_impersonate_group") : null;
|
||||
if (impersonatedGroup && !headers.has("X-Impersonate-Group")) {
|
||||
headers.set("X-Impersonate-Group", impersonatedGroup);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
throw new Error(
|
||||
"Request timed out. The API may still be starting or the bot is restarting — try again.",
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not reach the API. Is the bot process running?",
|
||||
);
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export type LogType = "Output" | "Warn" | "Error";
|
||||
|
||||
export interface LogEntry {
|
||||
type: LogType;
|
||||
timestamp: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LogsPayload {
|
||||
entries: LogEntry[];
|
||||
cursor: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface BotRestartResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
commands?: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
restartRequired?: boolean;
|
||||
reloaded?: string[];
|
||||
botRestart?: BotRestartResult;
|
||||
}
|
||||
|
||||
export interface CatalogAddon {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
description: string;
|
||||
repositoryUrl: string;
|
||||
price?: number;
|
||||
is_external?: number;
|
||||
external_store?: string;
|
||||
jsFiles: string[];
|
||||
configFiles: string[];
|
||||
fileCount?: number;
|
||||
installed?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
id: string;
|
||||
name: string;
|
||||
current: string;
|
||||
latest: string;
|
||||
updateAvailable: boolean;
|
||||
repository: string;
|
||||
branch: string;
|
||||
zipUrl: string;
|
||||
}
|
||||
|
||||
export type NotificationType = "bot" | "update" | "info" | "error";
|
||||
|
||||
export interface DashboardNotification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
href: string | null;
|
||||
createdAt: number;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationsPayload {
|
||||
items: DashboardNotification[];
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export type ModuleSetupStatus = "todo" | "progress" | "done";
|
||||
|
||||
export interface ModuleStatusInfo {
|
||||
status: ModuleSetupStatus;
|
||||
subtitle: string;
|
||||
passed?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface GuildSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
memberCount: number | null;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export type GuildHealthStatus =
|
||||
| "ok"
|
||||
| "missing_config"
|
||||
| "not_in_guild"
|
||||
| "extra_guilds"
|
||||
| "offline";
|
||||
|
||||
export interface GuildHealth {
|
||||
status: GuildHealthStatus;
|
||||
configuredGuildId: string | null;
|
||||
message: string | null;
|
||||
configuredGuild: GuildSummary | null;
|
||||
extraGuilds: GuildSummary[];
|
||||
}
|
||||
|
||||
export type DashboardAlertSeverity = "error" | "warning" | "info";
|
||||
|
||||
export interface DashboardAlertPayload {
|
||||
id: string;
|
||||
severity: DashboardAlertSeverity;
|
||||
category: string;
|
||||
title: string;
|
||||
message: string;
|
||||
action?: { label: string; href: string };
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GuildEmojiResource {
|
||||
id: string;
|
||||
name: string;
|
||||
animated: boolean;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface GuildResources {
|
||||
guildId: string;
|
||||
guildName: string;
|
||||
guildIcon?: string | null;
|
||||
roles: { id: string; name: string; color: string | null }[];
|
||||
channels: { id: string; name: string; type: number }[];
|
||||
categories: { id: string; name: string }[];
|
||||
emojis: GuildEmojiResource[];
|
||||
}
|
||||
|
||||
export interface BotStats {
|
||||
bot: {
|
||||
username: string;
|
||||
id: string;
|
||||
avatar: string;
|
||||
inviteUrl?: string;
|
||||
version: string;
|
||||
ping: number;
|
||||
uptime: number;
|
||||
};
|
||||
servers: number;
|
||||
users: number;
|
||||
tickets: { total: number; open: number; closed: number };
|
||||
modules?: Record<string, ModuleStatusInfo>;
|
||||
hosting: {
|
||||
ram_used: string;
|
||||
ram_total: string;
|
||||
ram_percent: number;
|
||||
cpu_load: string;
|
||||
uptime: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function maskConfigSecrets(content: string, filename: string): string {
|
||||
if (filename !== "supportbot") return content;
|
||||
return content.replace(
|
||||
/^(\s*Token:\s*)["'][^"']*["']/m,
|
||||
'$1"BOT_TOKEN"',
|
||||
);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<ApiResponse<DashboardUser>>("/api/auth/me"),
|
||||
|
||||
logout: () =>
|
||||
request<ApiResponse<unknown>>("/api/auth/logout", { method: "POST" }),
|
||||
|
||||
health: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
dashboard: boolean;
|
||||
botReady: boolean;
|
||||
oauthEnabled?: boolean;
|
||||
}>
|
||||
>("/api/health"),
|
||||
|
||||
stats: () => request<ApiResponse<BotStats>>("/api/stats"),
|
||||
|
||||
getAlerts: () => request<ApiResponse<DashboardAlertPayload[]>>("/api/alerts"),
|
||||
|
||||
getGuildStatus: () =>
|
||||
request<ApiResponse<GuildHealth>>("/api/guild/status"),
|
||||
|
||||
getGuildResources: (refresh = false) =>
|
||||
request<ApiResponse<GuildResources>>(
|
||||
`/api/guild/resources${refresh ? "?refresh=1" : ""}`,
|
||||
),
|
||||
|
||||
leaveGuild: (guildId: string) =>
|
||||
request<ApiResponse<GuildHealth>>(`/api/guild/${guildId}/leave`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
listConfigs: () =>
|
||||
[
|
||||
"supportbot",
|
||||
"ticket-panel",
|
||||
"commands",
|
||||
"messages",
|
||||
"supportbot-ai",
|
||||
] as const,
|
||||
|
||||
getConfigRaw: (file: string) =>
|
||||
request<ApiResponse<string>>(`/api/configs/${file}`),
|
||||
|
||||
saveConfigRaw: (filename: string, content: string) =>
|
||||
request<ApiResponse<unknown>>("/api/configs/raw", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ filename, content }),
|
||||
}),
|
||||
|
||||
getConfigJson: (file: string) =>
|
||||
request<ApiResponse<Record<string, unknown>>>(`/api/configs/json/${file}`),
|
||||
|
||||
updateConfigFields: (updates: Record<string, unknown>) =>
|
||||
request<ApiResponse<unknown>>("/api/configs/update-fields/multi", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(updates),
|
||||
}),
|
||||
|
||||
checkForUpdates: () =>
|
||||
request<ApiResponse<UpdateCheckResult[]>>("/api/system/update-check"),
|
||||
|
||||
runUpdate: (repoId: string, version: string, url: string) =>
|
||||
request<ApiResponse<unknown>>("/api/system/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repoId, version, url }),
|
||||
}),
|
||||
|
||||
reloadConfigs: (file?: string) =>
|
||||
request<ApiResponse<unknown>>("/api/system/reload-configs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(file ? { file } : {}),
|
||||
}),
|
||||
|
||||
getLogsMeta: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
types: LogType[];
|
||||
files: { type: LogType; date: string; file: string | null; size: number }[];
|
||||
}>
|
||||
>("/api/system/logs/meta"),
|
||||
|
||||
getLogs: (params: {
|
||||
types?: LogType[];
|
||||
cursor?: Record<string, number>;
|
||||
tail?: number;
|
||||
}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.types?.length) q.set("types", params.types.join(","));
|
||||
if (params.cursor && Object.keys(params.cursor).length > 0) {
|
||||
q.set("cursor", JSON.stringify(params.cursor));
|
||||
}
|
||||
if (params.tail != null) q.set("tail", String(params.tail));
|
||||
const qs = q.toString();
|
||||
return request<ApiResponse<LogsPayload>>(
|
||||
`/api/system/logs${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
|
||||
listTranscripts: () =>
|
||||
request<
|
||||
ApiResponse<
|
||||
{
|
||||
id: string;
|
||||
filename: string;
|
||||
createdAt: string;
|
||||
size?: number;
|
||||
ticketName?: string | null;
|
||||
}[]
|
||||
>
|
||||
>("/api/system/transcripts"),
|
||||
|
||||
listOpenTickets: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
guildId: string;
|
||||
departmentsEnabled?: boolean;
|
||||
configuredDepartments?: { id: string; name: string; emoji?: string }[];
|
||||
tickets: {
|
||||
ticket_id: string;
|
||||
user_id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
department: string;
|
||||
priority: string;
|
||||
created_at: number;
|
||||
}[];
|
||||
}>
|
||||
>("/api/system/tickets/open"),
|
||||
|
||||
createTicket: (data: { department?: string; subject?: string; reason?: string; userId?: string }) =>
|
||||
request<ApiResponse<{ message: string }>>("/api/system/tickets/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
getTicketMessages: (ticketId: string) =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
ticketId: string;
|
||||
messages: {
|
||||
id: string;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
globalName: string;
|
||||
avatar: string;
|
||||
bot: boolean;
|
||||
isDashboard?: boolean;
|
||||
};
|
||||
content: string;
|
||||
embeds?: { title?: string; description?: string; color?: string | null }[];
|
||||
attachments?: { id: string; name: string; url: string; contentType?: string }[];
|
||||
createdAt: number;
|
||||
}[];
|
||||
}>
|
||||
>(`/api/system/tickets/${encodeURIComponent(ticketId)}/messages`),
|
||||
|
||||
sendTicketReply: (
|
||||
ticketId: string,
|
||||
message: string,
|
||||
staffName?: string,
|
||||
attachments?: { filename: string; data: string }[],
|
||||
) =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
id: string;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
globalName: string;
|
||||
avatar: string;
|
||||
bot: boolean;
|
||||
isDashboard?: boolean;
|
||||
};
|
||||
content: string;
|
||||
attachments?: { id: string; name: string; url: string; contentType?: string }[];
|
||||
createdAt: number;
|
||||
}>
|
||||
>(`/api/system/tickets/${encodeURIComponent(ticketId)}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message, staffName, attachments }),
|
||||
}),
|
||||
|
||||
deleteTicketMessage: (ticketId: string, messageId: string) =>
|
||||
request<ApiResponse<unknown>>(
|
||||
`/api/system/tickets/${encodeURIComponent(ticketId)}/messages/${encodeURIComponent(messageId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
|
||||
editTicketMessage: (ticketId: string, messageId: string, content: string) =>
|
||||
request<ApiResponse<{ id: string; content: string }>>(
|
||||
`/api/system/tickets/${encodeURIComponent(ticketId)}/messages/${encodeURIComponent(messageId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
),
|
||||
|
||||
closeTicket: (ticketId: string, reason?: string) =>
|
||||
request<ApiResponse<unknown>>(
|
||||
`/api/system/tickets/${encodeURIComponent(ticketId)}/close`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
},
|
||||
),
|
||||
|
||||
getTranscript: (id: string) =>
|
||||
request<ApiResponse<string>>(`/api/system/transcripts/${id}`),
|
||||
|
||||
getTranscriptSettings: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
autoDelete: { enabled: boolean; afterDays: number };
|
||||
publicAccess: { enabled: boolean };
|
||||
publicBasePath?: string;
|
||||
}>
|
||||
>("/api/system/transcript-settings"),
|
||||
|
||||
updateTranscriptSettings: (body: {
|
||||
autoDelete?: { enabled?: boolean; afterDays?: number };
|
||||
publicAccess?: { enabled?: boolean };
|
||||
}) =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
autoDelete: { enabled: boolean; afterDays: number };
|
||||
publicAccess: { enabled: boolean };
|
||||
publicBasePath?: string;
|
||||
}>
|
||||
>("/api/system/transcript-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
deleteTranscript: (id: string) =>
|
||||
request<ApiResponse<unknown>>(`/api/system/transcripts/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
purgeTranscripts: () =>
|
||||
request<
|
||||
ApiResponse<{ deleted: number; skipped: boolean }>
|
||||
>("/api/system/transcripts/purge", { method: "POST" }),
|
||||
|
||||
getBranding: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
title: string;
|
||||
pageTitle: string;
|
||||
hasFavicon: boolean;
|
||||
faviconUrl: string | null;
|
||||
updatedAt: number | null;
|
||||
}>
|
||||
>("/api/branding"),
|
||||
|
||||
updateBranding: (body: { title?: string; pageTitle?: string }) =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
title: string;
|
||||
pageTitle: string;
|
||||
hasFavicon: boolean;
|
||||
faviconUrl: string | null;
|
||||
updatedAt: number | null;
|
||||
}>
|
||||
>("/api/branding", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
uploadBrandingFavicon: (dataUrl: string) =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
title: string;
|
||||
pageTitle: string;
|
||||
hasFavicon: boolean;
|
||||
faviconUrl: string | null;
|
||||
updatedAt: number | null;
|
||||
}>
|
||||
>("/api/branding/favicon", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dataUrl }),
|
||||
}),
|
||||
|
||||
removeBrandingFavicon: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
title: string;
|
||||
pageTitle: string;
|
||||
hasFavicon: boolean;
|
||||
faviconUrl: string | null;
|
||||
updatedAt: number | null;
|
||||
}>
|
||||
>("/api/branding/favicon", { method: "DELETE" }),
|
||||
|
||||
resetBranding: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
title: string;
|
||||
pageTitle: string;
|
||||
hasFavicon: boolean;
|
||||
faviconUrl: string | null;
|
||||
updatedAt: number | null;
|
||||
}>
|
||||
>("/api/branding/reset", { method: "POST" }),
|
||||
|
||||
getTranscriptTemplate: () =>
|
||||
request<ApiResponse<Record<string, unknown>>>(
|
||||
"/api/system/transcript-template",
|
||||
),
|
||||
|
||||
saveTranscriptTemplate: (data: Record<string, unknown>) =>
|
||||
request<ApiResponse<Record<string, unknown>>>(
|
||||
"/api/system/transcript-template",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
),
|
||||
|
||||
previewTranscriptTemplate: async (
|
||||
data: Record<string, unknown>,
|
||||
): Promise<string> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch("/api/system/transcript-template/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Preview failed (${res.status})`);
|
||||
}
|
||||
return await res.text();
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
},
|
||||
|
||||
getNotifications: () =>
|
||||
request<ApiResponse<NotificationsPayload>>("/api/notifications"),
|
||||
|
||||
markNotificationsRead: (ids: string[] | "all") =>
|
||||
request<ApiResponse<{ unreadCount: number }>>("/api/notifications/read", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
|
||||
dismissNotification: (id: string) =>
|
||||
request<ApiResponse<{ unreadCount: number }>>(`/api/notifications/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
listDashboardUsers: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
users: StoredDashboardUser[];
|
||||
roles: DashboardRole[];
|
||||
configFiles: string[];
|
||||
}>
|
||||
>("/api/dashboard-users"),
|
||||
|
||||
addDashboardUser: (body: {
|
||||
id: string;
|
||||
role: DashboardRole;
|
||||
username?: string;
|
||||
globalName?: string;
|
||||
permissions?: DashboardPermissions;
|
||||
enabled?: boolean;
|
||||
}) =>
|
||||
request<ApiResponse<StoredDashboardUser>>("/api/dashboard-users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
updateDashboardUser: (
|
||||
id: string,
|
||||
body: {
|
||||
role?: DashboardRole;
|
||||
enabled?: boolean;
|
||||
username?: string;
|
||||
globalName?: string;
|
||||
permissions?: DashboardPermissions;
|
||||
},
|
||||
) =>
|
||||
request<ApiResponse<StoredDashboardUser>>(`/api/dashboard-users/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
removeDashboardUser: (id: string) =>
|
||||
request<ApiResponse<unknown>>(`/api/dashboard-users/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
listGroups: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
groups: DashboardGroup[];
|
||||
}>
|
||||
>("/api/dashboard-groups"),
|
||||
|
||||
createGroup: (body: {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
discordRoleId?: string | null;
|
||||
permissions?: Partial<DashboardPermissions>;
|
||||
}) =>
|
||||
request<ApiResponse<DashboardGroup>>("/api/dashboard-groups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
updateGroup: (
|
||||
id: string,
|
||||
body: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
discordRoleId?: string | null;
|
||||
permissions?: Partial<DashboardPermissions>;
|
||||
},
|
||||
) =>
|
||||
request<ApiResponse<DashboardGroup>>(`/api/dashboard-groups/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
deleteGroup: (id: string) =>
|
||||
request<ApiResponse<unknown>>(`/api/dashboard-groups/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
|
||||
getAddonsCatalog: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
repository: string;
|
||||
addons: CatalogAddon[];
|
||||
}>
|
||||
>("/api/addons/catalog"),
|
||||
|
||||
getAddonsInstalled: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
installed: { id: string; name: string; jsFiles: string[]; configFiles: string[] }[];
|
||||
configs: string[];
|
||||
}>
|
||||
>("/api/addons/installed"),
|
||||
|
||||
listAddonConfigs: () =>
|
||||
request<ApiResponse<string[]>>("/api/addons/configs"),
|
||||
|
||||
installAddon: (name: string) =>
|
||||
request<ApiResponse<unknown>>("/api/addons/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
getAddonConfigJson: (file: string) =>
|
||||
request<ApiResponse<Record<string, unknown>>>(
|
||||
`/api/addons/configs/json/${encodeURIComponent(file)}`,
|
||||
),
|
||||
|
||||
saveAddonConfig: (filename: string, data: Record<string, unknown>) =>
|
||||
request<ApiResponse<unknown>>("/api/addons/configs", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ filename, data }),
|
||||
}),
|
||||
|
||||
buildAddon: (payload: { name: string; description: string; permission: string; embed: any }) =>
|
||||
request<ApiResponse<{ success: boolean }>>("/api/addons/build", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
getSetupStatus: () =>
|
||||
request<ApiResponse<import("@/lib/setup-types").SetupStatus>>("/api/setup/status"),
|
||||
|
||||
validateSetup: (body: Record<string, unknown>) =>
|
||||
request<ApiResponse<import("@/lib/setup-types").SetupValidateResponse>>(
|
||||
"/api/setup/validate",
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
),
|
||||
|
||||
resetDashboard: () =>
|
||||
request<ApiResponse<unknown>>("/api/setup/reset", {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
completeSetup: (body: Record<string, unknown>) =>
|
||||
request<
|
||||
ApiResponse<{ setupComplete: boolean; botRestart?: BotRestartResult }>
|
||||
>("/api/setup/complete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
};
|
||||
|
||||
export function displayDiscordName(
|
||||
user: Pick<DashboardUser, "username" | "globalName">,
|
||||
): string {
|
||||
return user.globalName || user.username;
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { DashboardHeader } from "@/components/dashboard-header";
|
||||
import { DashboardAlerts } from "@/components/dashboard-alerts";
|
||||
import { BotStatsProvider } from "@/context/BotStatsContext";
|
||||
import { NotificationsProvider } from "@/context/NotificationsContext";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<BotStatsProvider>
|
||||
<NotificationsProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="min-h-svh bg-background">
|
||||
<DashboardHeader />
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">
|
||||
<DashboardAlerts />
|
||||
<Outlet />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</NotificationsProvider>
|
||||
</BotStatsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AlertCircleIcon, FloppyDiskIcon } from "@hugeicons/core-free-icons";
|
||||
import { api } from "@/api/client";
|
||||
import { ConfigFieldInput } from "@/components/config/config-field";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { schemaFromObject } from "@/lib/generic-config-schema";
|
||||
import { getByPath, setByPath } from "@/lib/config-utils";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { notifyConfigSave } from "@/lib/notify-config-save";
|
||||
|
||||
interface AddonConfigEditorProps {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export function AddonConfigEditor({ filename }: AddonConfigEditorProps) {
|
||||
const { canEditConfig } = useAuth();
|
||||
const readOnly = !canEditConfig("supportbot");
|
||||
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||
const [original, setOriginal] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.getAddonConfigJson(filename);
|
||||
const raw = (res.data ?? {}) as Record<string, unknown>;
|
||||
setData(raw);
|
||||
setOriginal(structuredClone(raw));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load config");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filename]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const schema = data ? schemaFromObject(data) : null;
|
||||
|
||||
function patch(path: string, value: unknown) {
|
||||
setData((prev) => (prev ? setByPath(prev, path, value) : prev));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (readOnly || !data) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await notifyConfigSave(() =>
|
||||
api.saveAddonConfig(filename, data),
|
||||
);
|
||||
setMessage(res.message || "Saved.");
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !schema) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error || "Failed to load"}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const dirty =
|
||||
original && JSON.stringify(data) !== JSON.stringify(original);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{dirty ? (
|
||||
<span className="text-xs text-amber-500">Unsaved changes</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={load}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
|
||||
<Icon icon={FloppyDiskIcon} size={16} className="mr-1.5" />
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">View only</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<Icon icon={AlertCircleIcon} size={18} />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{message ? (
|
||||
<Alert className="border-primary/30 bg-primary/10 text-primary">
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{schema.map((section) => (
|
||||
<Card key={section.title} className="border-border bg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||
{section.description ? (
|
||||
<CardDescription>{section.description}</CardDescription>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
{section.fields.map((field) => (
|
||||
<div
|
||||
key={field.path}
|
||||
className={
|
||||
field.type === "textarea" || field.type === "stringList"
|
||||
? "sm:col-span-2"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<ConfigFieldInput
|
||||
field={field}
|
||||
value={getByPath(data, field.path)}
|
||||
readOnly={readOnly}
|
||||
onChange={(v) => patch(field.path, v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import {
|
||||
AiBrain01Icon,
|
||||
ArrowDown01Icon,
|
||||
BotIcon,
|
||||
CommandIcon,
|
||||
ComputerTerminal01Icon,
|
||||
DashboardSquare02Icon,
|
||||
File02Icon,
|
||||
Message01Icon,
|
||||
PlusSignIcon,
|
||||
PackageIcon,
|
||||
Settings02Icon,
|
||||
Shield01Icon,
|
||||
Ticket01Icon,
|
||||
UserMultiple02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { NavLink, useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useBranding } from "@/context/BrandingContext";
|
||||
import { api, type GuildResources } from "@/api/client";
|
||||
import { useBotStats } from "@/context/BotStatsContext";
|
||||
import { useAddonConfigs } from "@/hooks/use-addon-configs";
|
||||
import {
|
||||
addonConfigFileParam,
|
||||
addonConfigLabel,
|
||||
} from "@/lib/addon-config";
|
||||
import { canViewConfig, hasPermission } from "@/lib/permissions";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const mainNav: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: IconSvgElement;
|
||||
end?: boolean;
|
||||
permission?: string;
|
||||
}[] = [];
|
||||
|
||||
const sections: {
|
||||
label: string;
|
||||
items: { title: string; url: string; icon: IconSvgElement; permission?: string; end?: boolean }[];
|
||||
}[] = [
|
||||
{
|
||||
label: "Overview",
|
||||
items: [
|
||||
{ title: "Dashboard", url: "/", icon: DashboardSquare02Icon, end: true, permission: "overview" },
|
||||
{ title: "Console", url: "/logs", icon: ComputerTerminal01Icon, permission: "logs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Access & Logs",
|
||||
items: [
|
||||
{ title: "Users", url: "/users", icon: UserMultiple02Icon, permission: "users.view" },
|
||||
{ title: "Groups", url: "/groups", icon: Shield01Icon, permission: "users.view" },
|
||||
{ title: "Tickets", url: "/tickets", icon: Ticket01Icon, permission: "overview" },
|
||||
{ title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Configuration",
|
||||
items: [
|
||||
{ title: "Bot Settings", url: "/configs?file=supportbot", icon: BotIcon },
|
||||
{ title: "Ticket Panel", url: "/configs?file=ticket-panel", icon: Ticket01Icon },
|
||||
{ title: "AI Assistant", url: "/configs?file=supportbot-ai", icon: AiBrain01Icon },
|
||||
{ title: "Messages", url: "/configs?file=messages", icon: Message01Icon },
|
||||
{ title: "Staff Roles", url: "/configs?file=supportbot&tab=Roles", icon: UserMultiple02Icon },
|
||||
{ title: "Commands", url: "/configs?file=commands", icon: CommandIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "System",
|
||||
items: [
|
||||
{ title: "Addons", url: "/addons", icon: PackageIcon, permission: "settings.view" },
|
||||
{ title: "Addon Builder", url: "/addons/builder", icon: CommandIcon, permission: "settings.update" },
|
||||
{ title: "Dashboard Settings", url: "/settings", icon: Settings02Icon, permission: "settings.view" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function configFileFromUrl(url: string) {
|
||||
return new URL(url, "http://x").searchParams.get("file");
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const { branding } = useBranding();
|
||||
const { permissions } = useAuth();
|
||||
const { stats } = useBotStats();
|
||||
const { files: addonConfigFiles } = useAddonConfigs();
|
||||
const [searchParams] = useSearchParams();
|
||||
const activeFile = searchParams.get("file");
|
||||
|
||||
const addonSection =
|
||||
canViewConfig(permissions, "supportbot") && addonConfigFiles.length > 0
|
||||
? {
|
||||
label: "Addons",
|
||||
items: addonConfigFiles.map((filename) => ({
|
||||
title: addonConfigLabel(filename),
|
||||
url: `/configs?file=${addonConfigFileParam(filename)}`,
|
||||
icon: PackageIcon,
|
||||
end: false,
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
const visibleMainNav = mainNav.filter(
|
||||
(item) => !item.permission || hasPermission(permissions, item.permission),
|
||||
);
|
||||
|
||||
const visibleSections = sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => {
|
||||
if (item.permission) {
|
||||
return hasPermission(permissions, item.permission);
|
||||
}
|
||||
const file = configFileFromUrl(item.url);
|
||||
if (file && item.url.includes("/configs")) {
|
||||
return canViewConfig(permissions, file);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
|
||||
const allSections = addonSection
|
||||
? [...visibleSections, addonSection]
|
||||
: visibleSections;
|
||||
|
||||
const [guildResources, setGuildResources] = useState<GuildResources | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getGuildResources()
|
||||
.then((res) => {
|
||||
if (!cancelled && res.data) {
|
||||
setGuildResources(res.data);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const serverName =
|
||||
guildResources?.guildName ||
|
||||
(stats ? `${stats.servers} Server${stats.servers === 1 ? "" : "s"}` : "Connected Guild");
|
||||
const serverIcon = guildResources?.guildIcon;
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" className="border-r border-sidebar-border">
|
||||
<SidebarHeader className="gap-3 p-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
{branding.hasFavicon && branding.faviconUrl ? (
|
||||
<img
|
||||
src={`${branding.faviconUrl}${branding.updatedAt ? `?v=${branding.updatedAt}` : ""}`}
|
||||
alt=""
|
||||
className="size-7 shrink-0 rounded-md object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate text-lg font-bold tracking-tight">
|
||||
{branding.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 w-full justify-between border-border bg-secondary/40 px-3 font-normal hover:bg-secondary/60"
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate min-w-0">
|
||||
{serverIcon ? (
|
||||
<img
|
||||
src={serverIcon}
|
||||
alt=""
|
||||
className="size-5 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-5 rounded-full bg-primary/20 text-primary flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
{serverName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate text-xs font-semibold text-foreground">
|
||||
{serverName}
|
||||
</span>
|
||||
</span>
|
||||
<Icon icon={ArrowDown01Icon} size={14} className="text-muted-foreground shrink-0 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width]">
|
||||
<DropdownMenuItem disabled className="text-xs font-medium">
|
||||
Connected Guild: {serverName}
|
||||
</DropdownMenuItem>
|
||||
{stats ? (
|
||||
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
|
||||
{stats.users.toLocaleString()} members tracked
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="px-2">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{visibleMainNav.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cn(isActive && "bg-sidebar-accent font-medium")
|
||||
}
|
||||
>
|
||||
<Icon icon={item.icon} size={18} />
|
||||
<span>{item.title}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{allSections.map((section) => (
|
||||
<SidebarGroup key={section.label}>
|
||||
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/80">
|
||||
{section.label}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{section.items.map((item) => {
|
||||
const file = configFileFromUrl(item.url);
|
||||
const isConfigActive =
|
||||
Boolean(file) &&
|
||||
activeFile === file &&
|
||||
item.url.includes("/configs");
|
||||
return (
|
||||
<SidebarMenuItem key={`${section.label}-${item.title}`}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.end}
|
||||
className={({ isActive }) => cn(
|
||||
(isActive || isConfigActive) && "bg-sidebar-accent font-medium",
|
||||
)}
|
||||
>
|
||||
<Icon icon={item.icon} size={18} />
|
||||
<span>{item.title}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import { CommandIcon } from "@hugeicons/core-free-icons";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export interface CommandEntry {
|
||||
key: string;
|
||||
Command?: string;
|
||||
Description?: string;
|
||||
Enabled?: boolean;
|
||||
Permission?: string[];
|
||||
}
|
||||
|
||||
interface CommandsEditorProps {
|
||||
data: Record<string, unknown>;
|
||||
onChange: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function isCommandBlock(v: unknown): v is CommandEntry {
|
||||
return (
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
"Command" in (v as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
export function getCommandEntries(
|
||||
data: Record<string, unknown>,
|
||||
): CommandEntry[] {
|
||||
return Object.entries(data)
|
||||
.filter(([, v]) => isCommandBlock(v))
|
||||
.map(([key, v]) => ({ key, ...(v as CommandEntry) }));
|
||||
}
|
||||
|
||||
export function CommandsEditor({ data, onChange }: CommandsEditorProps) {
|
||||
const entries = getCommandEntries(data);
|
||||
|
||||
function updateEntry(
|
||||
key: string,
|
||||
patch: Partial<CommandEntry>,
|
||||
) {
|
||||
const current = data[key] as Record<string, unknown>;
|
||||
onChange({
|
||||
...data,
|
||||
[key]: { ...current, ...patch },
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No commands found in this config.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{entries.map((entry) => (
|
||||
<Card key={entry.key} className="border-border bg-card">
|
||||
<CardHeader className="flex flex-row items-center gap-2 space-y-0 pb-2">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/15 text-primary">
|
||||
<Icon icon={CommandIcon as IconSvgElement} size={18} />
|
||||
</div>
|
||||
<CardTitle className="text-base">{entry.key}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={`${entry.key}-enabled`}>Enabled</Label>
|
||||
<Switch
|
||||
id={`${entry.key}-enabled`}
|
||||
checked={entry.Enabled !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateEntry(entry.key, { Enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Slash command</Label>
|
||||
<Input
|
||||
value={entry.Command ?? ""}
|
||||
onChange={(e) =>
|
||||
updateEntry(entry.key, { Command: e.target.value })
|
||||
}
|
||||
className="border-border bg-secondary/30 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Description</Label>
|
||||
<Input
|
||||
value={entry.Description ?? ""}
|
||||
onChange={(e) =>
|
||||
updateEntry(entry.key, { Description: e.target.value })
|
||||
}
|
||||
className="border-border bg-secondary/30"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { ConfigField } from "@/lib/config-schemas";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { normalizeHex } from "@/lib/normalize-hex";
|
||||
import { StringListEditor } from "./string-list-editor";
|
||||
import { GoogleFontPicker } from "./google-font-picker";
|
||||
import {
|
||||
DiscordResourcePicker,
|
||||
type DiscordResourceKind,
|
||||
} from "./discord-resource-picker";
|
||||
import { EmojiPicker } from "./emoji-picker";
|
||||
|
||||
const SECRET_PLACEHOLDER = "BOT_TOKEN";
|
||||
const AI_KEY_PLACEHOLDER = "MODEL_API_KEY";
|
||||
|
||||
interface ConfigFieldInputProps {
|
||||
field: ConfigField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ConfigFieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: ConfigFieldInputProps) {
|
||||
const id = field.path.replace(/\./g, "-");
|
||||
|
||||
if (field.type === "googleFont") {
|
||||
return (
|
||||
<GoogleFontPicker
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-secondary/20 px-3 py-2.5">
|
||||
<div>
|
||||
<Label htmlFor={id} className="font-medium">
|
||||
{field.label}
|
||||
</Label>
|
||||
{field.description ? (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={Boolean(value)}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "stringList") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
{field.description ? (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
) : null}
|
||||
<StringListEditor
|
||||
value={Array.isArray(value) ? (value as string[]) : []}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "select" && field.options) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{field.label}</Label>
|
||||
<Select
|
||||
value={String(value ?? "")}
|
||||
disabled={readOnly}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={id} className="border-border bg-secondary/30">
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "color") {
|
||||
const hex = normalizeHex(String(value ?? "#000000"));
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{field.label}</Label>
|
||||
{field.description ? (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<ColorPicker value={hex} onChange={onChange} />
|
||||
<Input
|
||||
id={id}
|
||||
value={hex}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={(e) => onChange(normalizeHex(e.target.value, hex))}
|
||||
className="border-border bg-secondary/30 font-mono uppercase"
|
||||
placeholder="#000000"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "emoji") {
|
||||
return (
|
||||
<EmojiPicker
|
||||
id={id}
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
field.type === "discordChannel" ||
|
||||
field.type === "discordRole" ||
|
||||
field.type === "discordCategory"
|
||||
) {
|
||||
const kind: DiscordResourceKind =
|
||||
field.type === "discordRole"
|
||||
? "role"
|
||||
: field.type === "discordCategory"
|
||||
? "category"
|
||||
: "channel";
|
||||
|
||||
return (
|
||||
<DiscordResourcePicker
|
||||
id={id}
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
kind={kind}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "secret") {
|
||||
const display =
|
||||
value === SECRET_PLACEHOLDER ||
|
||||
value === AI_KEY_PLACEHOLDER ||
|
||||
!value
|
||||
? ""
|
||||
: "••••••••••••";
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{field.label}</Label>
|
||||
{field.description ? (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
) : null}
|
||||
<Input
|
||||
id={id}
|
||||
type="password"
|
||||
value={display}
|
||||
disabled={readOnly}
|
||||
placeholder={field.placeholder ?? "Unchanged if empty"}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v) onChange(v);
|
||||
else onChange(field.placeholder ?? SECRET_PLACEHOLDER);
|
||||
}}
|
||||
className="border-border bg-secondary/30"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sharedClass = "border-border bg-secondary/30";
|
||||
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{field.label}</Label>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={String(value ?? "")}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`min-h-[88px] ${sharedClass}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{field.label}</Label>
|
||||
{field.description ? (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
) : null}
|
||||
<Input
|
||||
id={id}
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
value={value === undefined || value === null ? "" : String(value)}
|
||||
disabled={readOnly}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
field.type === "number"
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
field.placeholder ??
|
||||
(field.type === "discordId" ? "Discord snowflake ID" : undefined)
|
||||
}
|
||||
className={sharedClass}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AlertCircleIcon, FloppyDiskIcon, Add01Icon, Delete02Icon } from "@hugeicons/core-free-icons";
|
||||
import { api, maskConfigSecrets } from "@/api/client";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getSchema, type ConfigSchema } from "@/lib/config-schemas";
|
||||
import { getByPath, setByPath } from "@/lib/config-utils";
|
||||
import { mergeSchemaWithData } from "@/lib/merge-config-schema";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { notifyConfigSave } from "@/lib/notify-config-save";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import Editor from "@monaco-editor/react";
|
||||
import { ConfigFieldInput } from "./config-field";
|
||||
import { CommandsEditor } from "./commands-editor";
|
||||
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
|
||||
type EditorMode = "visual" | "yaml";
|
||||
|
||||
interface ConfigVisualEditorProps {
|
||||
configFile: string;
|
||||
}
|
||||
|
||||
function maskSecretsInData(
|
||||
file: string,
|
||||
data: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
let next = structuredClone(data);
|
||||
if (file === "supportbot") {
|
||||
const token = getByPath(next, "General.Token");
|
||||
if (token && token !== "BOT_TOKEN") {
|
||||
next = setByPath(next, "General.Token", "BOT_TOKEN");
|
||||
}
|
||||
}
|
||||
if (file === "supportbot-ai") {
|
||||
const key = getByPath(next, "General.Model_API_Key");
|
||||
if (key && key !== "MODEL_API_KEY") {
|
||||
next = setByPath(next, "General.Model_API_Key", "MODEL_API_KEY");
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildUpdates(
|
||||
file: string,
|
||||
schema: ConfigSchema,
|
||||
data: Record<string, unknown>,
|
||||
original: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const section of schema) {
|
||||
for (const field of section.fields) {
|
||||
const val = getByPath(data, field.path);
|
||||
const orig = getByPath(original, field.path);
|
||||
if (field.type === "secret") {
|
||||
if (
|
||||
val === "BOT_TOKEN" ||
|
||||
val === "MODEL_API_KEY" ||
|
||||
val === "" ||
|
||||
val === undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(val) !== JSON.stringify(orig)) {
|
||||
updates[`${file}:${field.path}`] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
interface GroupedFields {
|
||||
standalone: ConfigField[];
|
||||
groups: { key: string; name: string; fields: ConfigField[] }[];
|
||||
}
|
||||
|
||||
function getGroupedSectionFields(
|
||||
fields: ConfigField[],
|
||||
data: Record<string, unknown> | null
|
||||
): GroupedFields {
|
||||
const subGroupMap = new Map<string, ConfigField[]>();
|
||||
const standalone: ConfigField[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
const parts = field.path.split(".");
|
||||
if (parts.length >= 5 && parts[1] === "DepartmentSystem" && parts[2] === "Departments") {
|
||||
const groupKey = parts[3];
|
||||
const list = subGroupMap.get(groupKey) ?? [];
|
||||
list.push(field);
|
||||
subGroupMap.set(groupKey, list);
|
||||
} else if (parts.length >= 3) {
|
||||
const groupKey = parts[1];
|
||||
const list = subGroupMap.get(groupKey) ?? [];
|
||||
list.push(field);
|
||||
subGroupMap.set(groupKey, list);
|
||||
} else {
|
||||
standalone.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
if (subGroupMap.size === 0) {
|
||||
return { standalone: fields, groups: [] };
|
||||
}
|
||||
|
||||
const groups = [...subGroupMap.entries()].map(([groupKey, groupFields]) => {
|
||||
const fieldOrder = [
|
||||
"enabled",
|
||||
"name",
|
||||
"description",
|
||||
"emoji",
|
||||
"category",
|
||||
"role",
|
||||
"channelprefix",
|
||||
"defaultpriority",
|
||||
"questions",
|
||||
];
|
||||
|
||||
groupFields.sort((a, b) => {
|
||||
const aKey = a.path.split(".").pop()?.toLowerCase() || "";
|
||||
const bKey = b.path.split(".").pop()?.toLowerCase() || "";
|
||||
const aIdx = fieldOrder.indexOf(aKey);
|
||||
const bIdx = fieldOrder.indexOf(bKey);
|
||||
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
|
||||
if (aIdx !== -1) return -1;
|
||||
if (bIdx !== -1) return 1;
|
||||
return aKey.localeCompare(bKey);
|
||||
});
|
||||
|
||||
const nameField = groupFields.find(
|
||||
(f) => f.path.toLowerCase().endsWith(".name") || f.path.toLowerCase().endsWith(".title")
|
||||
);
|
||||
let displayName = "";
|
||||
if (nameField && data) {
|
||||
const val = getByPath(data, nameField.path);
|
||||
if (typeof val === "string" && val.trim()) {
|
||||
displayName = val.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
displayName = groupKey
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
return {
|
||||
key: groupKey,
|
||||
name: displayName,
|
||||
fields: groupFields,
|
||||
};
|
||||
});
|
||||
|
||||
return { standalone, groups };
|
||||
}
|
||||
|
||||
export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) {
|
||||
const { permissions: effectivePermissions, canEditConfig } = useAuth();
|
||||
const canRawYaml = hasPermission(effectivePermissions, "configs.raw_yaml");
|
||||
const readOnly = !canEditConfig(configFile);
|
||||
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||
const [original, setOriginal] = useState<Record<string, unknown> | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [mode, setMode] = useState<EditorMode>("visual");
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRawYaml && mode === "yaml") {
|
||||
setMode("visual");
|
||||
}
|
||||
}, [canRawYaml, mode]);
|
||||
const [rawYaml, setRawYaml] = useState("");
|
||||
const [rawOriginal, setRawOriginal] = useState("");
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeSectionTitle, setActiveSectionTitle] = useState<string | null>(
|
||||
() => searchParams.get("tab") || null
|
||||
);
|
||||
|
||||
const [addDeptOpen, setAddDeptOpen] = useState(false);
|
||||
const [newDeptKey, setNewDeptKey] = useState("");
|
||||
const [newDeptName, setNewDeptName] = useState("");
|
||||
|
||||
const handleCreateDepartment = () => {
|
||||
const key = newDeptKey.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
||||
if (!key || !data) return;
|
||||
const name = newDeptName.trim() || key;
|
||||
const prefix = `Ticket.DepartmentSystem.Departments.${key}`;
|
||||
const next = { ...data };
|
||||
setByPath(next, `${prefix}.Name`, name);
|
||||
setByPath(next, `${prefix}.Description`, `Open a ${name} ticket`);
|
||||
setByPath(next, `${prefix}.Emoji`, "🎫");
|
||||
setByPath(next, `${prefix}.Category`, "CATEGORY_ID");
|
||||
setByPath(next, `${prefix}.Role`, "ROLE_ID");
|
||||
setByPath(next, `${prefix}.ChannelPrefix`, `${key}-`);
|
||||
setByPath(next, `${prefix}.DefaultPriority`, "normal");
|
||||
setByPath(next, `${prefix}.Questions`, ["What do you need assistance with?"]);
|
||||
setData(next);
|
||||
setAddDeptOpen(false);
|
||||
setNewDeptKey("");
|
||||
setNewDeptName("");
|
||||
};
|
||||
|
||||
const handleDeleteDepartment = (deptKey: string) => {
|
||||
if (!data) return;
|
||||
const prefix = `Ticket.DepartmentSystem.Departments.${deptKey}`;
|
||||
const next = JSON.parse(JSON.stringify(data));
|
||||
Object.keys(next).forEach((k) => {
|
||||
if (k.startsWith(prefix) || k === prefix) {
|
||||
delete next[k];
|
||||
}
|
||||
});
|
||||
if (next.Ticket && typeof next.Ticket === "object") {
|
||||
const deptSys = (next.Ticket as any).DepartmentSystem;
|
||||
if (deptSys && typeof deptSys === "object" && deptSys.Departments && typeof deptSys.Departments === "object") {
|
||||
delete deptSys.Departments[deptKey];
|
||||
}
|
||||
}
|
||||
setData(next);
|
||||
};
|
||||
|
||||
const baseSchema = getSchema(configFile);
|
||||
|
||||
const effectiveSchema = useMemo(() => {
|
||||
if (!baseSchema || baseSchema === "commands" || !data) return baseSchema;
|
||||
return mergeSchemaWithData(baseSchema, data);
|
||||
}, [baseSchema, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveSchema && effectiveSchema !== "commands") {
|
||||
const tabParam = searchParams.get("tab");
|
||||
if (tabParam && effectiveSchema.find(s => s.title.toLowerCase() === tabParam.toLowerCase())) {
|
||||
const found = effectiveSchema.find(s => s.title.toLowerCase() === tabParam.toLowerCase());
|
||||
if (found && found.title !== activeSectionTitle) {
|
||||
setActiveSectionTitle(found.title);
|
||||
}
|
||||
} else if (!activeSectionTitle || !effectiveSchema.find(s => s.title === activeSectionTitle)) {
|
||||
setActiveSectionTitle(effectiveSchema[0]?.title || null);
|
||||
}
|
||||
}
|
||||
}, [effectiveSchema, activeSectionTitle, searchParams]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.getConfigJson(configFile);
|
||||
const raw = (res.data ?? {}) as Record<string, unknown>;
|
||||
const masked = maskSecretsInData(configFile, raw);
|
||||
setData(masked);
|
||||
setOriginal(masked);
|
||||
|
||||
const rawRes = await api.getConfigRaw(configFile);
|
||||
const content = rawRes.data ?? "";
|
||||
const maskedRaw = maskConfigSecrets(content, configFile);
|
||||
setRawYaml(maskedRaw);
|
||||
setRawOriginal(maskedRaw);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load config");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [configFile]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function patch(path: string, value: unknown) {
|
||||
setData((prev) => (prev ? setByPath(prev, path, value) : prev));
|
||||
}
|
||||
|
||||
async function handleSaveYaml() {
|
||||
if (readOnly || rawYaml === rawOriginal) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await notifyConfigSave(() =>
|
||||
api.saveConfigRaw(configFile, rawYaml),
|
||||
);
|
||||
setMessage(res.message || "Config saved.");
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (readOnly || !data || !original) return;
|
||||
if (mode === "yaml") {
|
||||
await handleSaveYaml();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
if (baseSchema === "commands") {
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(data)) {
|
||||
if (JSON.stringify(data[key]) !== JSON.stringify(original[key])) {
|
||||
updates[`${configFile}:${key}`] = data[key];
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length === 0) {
|
||||
setMessage("No changes to save.");
|
||||
return;
|
||||
}
|
||||
const res = await notifyConfigSave(() =>
|
||||
api.updateConfigFields(updates),
|
||||
);
|
||||
setMessage(res.message || "");
|
||||
} else if (effectiveSchema && effectiveSchema !== "commands") {
|
||||
const updates = buildUpdates(
|
||||
configFile,
|
||||
effectiveSchema,
|
||||
data,
|
||||
original,
|
||||
);
|
||||
if (Object.keys(updates).length === 0) {
|
||||
setMessage("No changes to save.");
|
||||
return;
|
||||
}
|
||||
const res = await notifyConfigSave(() =>
|
||||
api.updateConfigFields(updates),
|
||||
);
|
||||
setMessage(res.message || "");
|
||||
}
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (baseSchema === null) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No visual editor available for this file.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error || "Failed to load"}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const dirtyVisual =
|
||||
original && JSON.stringify(data) !== JSON.stringify(original);
|
||||
const dirtyYaml = rawYaml !== rawOriginal;
|
||||
const dirty = mode === "yaml" ? dirtyYaml : dirtyVisual;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{canRawYaml ? (
|
||||
<div className="flex rounded-lg border border-border p-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === "visual" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setMode("visual")}
|
||||
>
|
||||
Visual
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === "yaml" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setMode("yaml")}
|
||||
>
|
||||
YAML
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{dirty ? (
|
||||
<span className="text-xs text-amber-500">Unsaved changes</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={load}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
|
||||
<Icon icon={FloppyDiskIcon} size={16} className="mr-1.5" />
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">View only</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<Icon icon={AlertCircleIcon} size={18} />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{message ? (
|
||||
<Alert className="border-primary/30 bg-primary/10 text-primary">
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{mode === "yaml" ? (
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Raw YAML</CardTitle>
|
||||
<CardDescription>
|
||||
Edit the full file. Use this for nested lists (e.g. welcome buttons)
|
||||
or any option not in Visual mode.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[min(520px,60vh)] overflow-hidden rounded-b-xl border-t border-border p-0">
|
||||
<Editor
|
||||
height="100%"
|
||||
language="yaml"
|
||||
theme="vs-dark"
|
||||
value={rawYaml}
|
||||
onChange={(value) => setRawYaml(value || "")}
|
||||
options={{
|
||||
readOnly: readOnly,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
wordWrap: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 16 },
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : baseSchema === "commands" ? (
|
||||
<CommandsEditor
|
||||
data={data}
|
||||
onChange={(next) => setData(next)}
|
||||
/>
|
||||
) : effectiveSchema && effectiveSchema !== "commands" ? (
|
||||
<div className="grid gap-6 lg:grid-cols-[200px_1fr]">
|
||||
<nav className="flex flex-col gap-1">
|
||||
{effectiveSchema.map((section) => (
|
||||
<button
|
||||
key={section.title}
|
||||
onClick={() => setActiveSectionTitle(section.title)}
|
||||
className={cn(
|
||||
"text-left px-3 py-2 text-sm rounded-md transition-colors",
|
||||
activeSectionTitle === section.title
|
||||
? "bg-primary text-primary-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{section.title}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="min-w-0">
|
||||
{effectiveSchema
|
||||
.filter((section) => section.title === activeSectionTitle)
|
||||
.map((section) => (
|
||||
<Card key={section.title} className="border-border bg-card">
|
||||
<CardHeader className="pb-3 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||
{section.description ? (
|
||||
<CardDescription>{section.description}</CardDescription>
|
||||
) : null}
|
||||
</div>
|
||||
{section.title === "Ticket Departments" && !readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setAddDeptOpen(true)}
|
||||
className="gap-1.5 text-xs h-8 shrink-0"
|
||||
>
|
||||
<Icon icon={Add01Icon} size={14} /> Add Department
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{(() => {
|
||||
const { standalone, groups } = getGroupedSectionFields(section.fields, data);
|
||||
|
||||
return (
|
||||
<>
|
||||
{standalone.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{standalone.map((field) => (
|
||||
<div
|
||||
key={field.path}
|
||||
className={
|
||||
field.type === "textarea" || field.type === "stringList"
|
||||
? "sm:col-span-2"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<ConfigFieldInput
|
||||
field={field}
|
||||
value={getByPath(data, field.path)}
|
||||
readOnly={readOnly}
|
||||
onChange={(v) => patch(field.path, v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
className="rounded-xl border border-border bg-card/60 p-4 space-y-4 shadow-sm transition-all hover:border-primary/40"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border/60 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-base text-foreground">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="text-xs font-mono px-2.5 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20 font-medium">
|
||||
{section.title === "Ticket Departments" ? `Ticket.${group.key}` : group.key}
|
||||
</span>
|
||||
</div>
|
||||
{section.title === "Ticket Departments" && group.key !== "DepartmentSystem" && !readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteDepartment(group.key)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
title="Delete Department"
|
||||
>
|
||||
<Icon icon={Delete02Icon} size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{group.fields.map((field) => (
|
||||
<div
|
||||
key={field.path}
|
||||
className={
|
||||
field.type === "textarea" || field.type === "stringList"
|
||||
? "sm:col-span-2"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<ConfigFieldInput
|
||||
field={field}
|
||||
value={getByPath(data, field.path)}
|
||||
readOnly={readOnly}
|
||||
onChange={(v) => patch(field.path, v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Add Department Modal */}
|
||||
<Dialog open={addDeptOpen} onOpenChange={setAddDeptOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">Add Ticket Department</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Create a new support department for ticket creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold">Department Key (Internal ID)</Label>
|
||||
<Input
|
||||
placeholder="billing"
|
||||
value={newDeptKey}
|
||||
onChange={(e) => setNewDeptKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
|
||||
className="text-xs font-mono border-border bg-secondary/30"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">e.g. <code>billing</code>, <code>technical</code>, <code>sales</code></p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold">Department Display Name</Label>
|
||||
<Input
|
||||
placeholder="Billing & Sales Support"
|
||||
value={newDeptName}
|
||||
onChange={(e) => setNewDeptName(e.target.value)}
|
||||
className="text-xs border-border bg-secondary/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" size="sm" onClick={() => setAddDeptOpen(false)} className="text-xs">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreateDepartment} disabled={!newDeptKey.trim()} className="text-xs bg-primary font-semibold">
|
||||
Create Department
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Search01Icon, UnfoldMoreIcon } from "@hugeicons/core-free-icons";
|
||||
import { api, type GuildResources } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type DiscordResourceKind = "channel" | "role" | "category";
|
||||
|
||||
interface DiscordResourcePickerProps {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
kind: DiscordResourceKind;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function DiscordResourcePicker({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
kind,
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: DiscordResourcePickerProps) {
|
||||
const [resources, setResources] = useState<GuildResources | null>(null);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api
|
||||
.getGuildResources()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.data) {
|
||||
setResources(res.data);
|
||||
setLoadError("");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : "Could not load server list",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!resources) return [];
|
||||
if (kind === "role") return resources.roles;
|
||||
if (kind === "category") return resources.categories;
|
||||
return resources.channels;
|
||||
}, [resources, kind]);
|
||||
|
||||
const current = String(value ?? "");
|
||||
const matched = options.find((o) => o.id === current);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!search.trim()) return options;
|
||||
const q = search.toLowerCase().trim();
|
||||
return options.filter(
|
||||
(opt) => opt.name.toLowerCase().includes(q) || opt.id.includes(q),
|
||||
);
|
||||
}, [options, search]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{description ? (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
|
||||
{resources ? (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={readOnly || loading}
|
||||
className="w-full justify-between border-border bg-secondary/30 text-left font-normal h-10 px-3 hover:bg-secondary/50"
|
||||
>
|
||||
<span className="truncate">
|
||||
{matched ? (
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium text-foreground truncate">{matched.name}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{matched.id}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{loading
|
||||
? "Loading server list…"
|
||||
: `Select ${kind} from ${resources.guildName}…`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Icon icon={UnfoldMoreIcon} size={16} className="shrink-0 opacity-50 ml-2" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-2 bg-popover border-border shadow-2xl rounded-xl flex flex-col gap-2 z-50">
|
||||
<div className="relative flex items-center">
|
||||
<Icon icon={Search01Icon} size={14} className="absolute left-2.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={`Search ${kind}s by name or ID…`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 pl-8 text-xs border-border bg-secondary/20 focus-visible:ring-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-56 space-y-1 pr-1">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="p-3 text-center text-xs text-muted-foreground">
|
||||
No {kind}s found matching "{search}"
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.id);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs transition-colors text-left",
|
||||
opt.id === current
|
||||
? "bg-primary/15 text-primary font-semibold border border-primary/20"
|
||||
: "hover:bg-secondary/60 text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate mr-2 font-medium">{opt.name}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground shrink-0">
|
||||
{opt.id}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
value={current}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
kind === "role"
|
||||
? "Role ID (ROLE_ID)"
|
||||
: kind === "category"
|
||||
? "Category ID"
|
||||
: "Channel ID"
|
||||
}
|
||||
className="border-border bg-secondary/30 font-mono text-xs"
|
||||
/>
|
||||
|
||||
{loadError ? (
|
||||
<p className="text-xs text-amber-500">{loadError}</p>
|
||||
) : null}
|
||||
{!loadError && !resources && !loading ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set General.GuildId and invite the bot to pick from a list.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Search01Icon } from "@hugeicons/core-free-icons";
|
||||
import { api, type GuildResources } from "@/api/client";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UnicodeEmojiPanel } from "@/components/config/unicode-emoji-panel";
|
||||
import {
|
||||
emojiCdnUrl,
|
||||
formatCustomEmoji,
|
||||
parseEmojiValue,
|
||||
resolveEmojiPreview,
|
||||
type GuildEmojiOption,
|
||||
} from "@/lib/emoji-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EmojiPickerProps {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function EmojiPreview({
|
||||
value,
|
||||
guildEmojis,
|
||||
size = "md",
|
||||
}: {
|
||||
value: unknown;
|
||||
guildEmojis: GuildEmojiOption[];
|
||||
size?: "md" | "lg";
|
||||
}) {
|
||||
const parsed = resolveEmojiPreview(value, guildEmojis);
|
||||
const imgSize = size === "lg" ? "size-10" : "size-7";
|
||||
const textSize = size === "lg" ? "text-3xl" : "text-2xl";
|
||||
|
||||
if (parsed.kind === "empty") {
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">No emoji selected</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.kind === "custom" && parsed.id) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-3">
|
||||
<img
|
||||
src={emojiCdnUrl(parsed.id, parsed.animated)}
|
||||
alt={parsed.name ?? "custom emoji"}
|
||||
className={cn(imgSize, "shrink-0 rounded-md")}
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="flex flex-col items-start gap-0.5">
|
||||
<span className="text-sm font-medium">:{parsed.name}:</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{parsed.value}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn(textSize, "leading-none")} title={parsed.value}>
|
||||
{parsed.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmojiPicker({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: EmojiPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<"server" | "unicode">("server");
|
||||
const [resources, setResources] = useState<GuildResources | null>(null);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const loadGuildEmojis = useCallback(async (refresh = false) => {
|
||||
setLoading(true);
|
||||
setLoadError("");
|
||||
try {
|
||||
const res = await api.getGuildResources(refresh);
|
||||
if (res.data) {
|
||||
setResources(res.data);
|
||||
if (!Array.isArray(res.data.emojis)) {
|
||||
setLoadError(
|
||||
"Server response is missing emojis. Restart the bot after updating.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setLoadError("No guild data returned.");
|
||||
}
|
||||
} catch (err) {
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : "Could not load server emojis",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadGuildEmojis(true);
|
||||
}, [open, loadGuildEmojis]);
|
||||
|
||||
const guildEmojis = useMemo(
|
||||
() => (Array.isArray(resources?.emojis) ? resources.emojis : []),
|
||||
[resources],
|
||||
);
|
||||
|
||||
const filteredGuild = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return guildEmojis;
|
||||
return guildEmojis.filter((e) => e.name.toLowerCase().includes(q));
|
||||
}, [guildEmojis, query]);
|
||||
|
||||
const current = String(value ?? "");
|
||||
const parsed = parseEmojiValue(current);
|
||||
|
||||
function selectUnicode(emoji: string) {
|
||||
onChange(emoji);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function selectGuild(emoji: GuildEmojiOption) {
|
||||
onChange(formatCustomEmoji(emoji.name, emoji.id, emoji.animated));
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function clearEmoji() {
|
||||
onChange("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{description ? (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<EmojiPreview value={value} guildEmojis={guildEmojis} size="lg" />
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={readOnly}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Choose emoji
|
||||
</Button>
|
||||
{current ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={readOnly}
|
||||
onClick={clearEmoji}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[min(90vh,720px)] max-w-lg gap-0 overflow-hidden p-0 sm:max-w-xl">
|
||||
<DialogHeader className="space-y-1 border-b border-border px-5 py-4">
|
||||
<DialogTitle>Choose emoji</DialogTitle>
|
||||
<DialogDescription>
|
||||
Server emojis save as{" "}
|
||||
<code className="rounded bg-muted px-1 text-xs"><:name:id></code>
|
||||
. Standard emojis save as Unicode characters.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as "server" | "unicode")}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList className="mx-5 mt-3 grid h-9 w-auto grid-cols-2">
|
||||
<TabsTrigger value="server">Server</TabsTrigger>
|
||||
<TabsTrigger value="unicode">All emojis</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="server" className="mt-0 flex-1 px-5 pb-4 pt-3">
|
||||
<div className="relative mb-3">
|
||||
<Icon
|
||||
icon={Search01Icon}
|
||||
size={16}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search by name…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-9 border-border bg-secondary/30 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{resources ? (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{resources.guildName} · {guildEmojis.length} custom emoji
|
||||
{guildEmojis.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<ScrollArea className="h-[min(50vh,320px)] rounded-lg border border-border/50 bg-secondary/20">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-6 gap-2 p-3 sm:grid-cols-8">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="space-y-3 p-4 text-center">
|
||||
<p className="text-sm text-destructive">{loadError}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadGuildEmojis(true)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredGuild.length === 0 ? (
|
||||
<p className="p-6 text-center text-sm text-muted-foreground">
|
||||
{guildEmojis.length === 0
|
||||
? "No custom emojis in this server, or the bot needs the Guild Emojis intent (restart after update)."
|
||||
: "No emojis match your search."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-6 gap-1.5 p-2 sm:grid-cols-8">
|
||||
{filteredGuild.map((emoji) => {
|
||||
const selected =
|
||||
parsed.kind === "custom" && parsed.id === emoji.id;
|
||||
return (
|
||||
<button
|
||||
key={emoji.id}
|
||||
type="button"
|
||||
title={`:${emoji.name}:`}
|
||||
className={cn(
|
||||
"flex aspect-square items-center justify-center rounded-lg border border-transparent bg-background/50 transition-colors hover:border-primary/30 hover:bg-accent",
|
||||
selected &&
|
||||
"border-primary/50 bg-primary/15 ring-2 ring-primary/30",
|
||||
)}
|
||||
onClick={() => selectGuild(emoji)}
|
||||
>
|
||||
<img
|
||||
src={emoji.url ?? emojiCdnUrl(emoji.id, emoji.animated)}
|
||||
alt={emoji.name}
|
||||
className="size-8 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="unicode" className="mt-0 px-5 pb-4 pt-3">
|
||||
<UnicodeEmojiPanel onPick={selectUnicode} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Input
|
||||
value={current}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Unicode or <:name:id>"
|
||||
className="border-border bg-secondary/30 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect } from "react";
|
||||
import type { ConfigField } from "@/lib/config-schemas";
|
||||
import {
|
||||
GOOGLE_FONTS,
|
||||
SYSTEM_FONT_ID,
|
||||
fontFamilyCss,
|
||||
injectGoogleFontsStylesheet,
|
||||
normalizeGoogleFontValue,
|
||||
} from "@/lib/google-fonts";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface GoogleFontPickerProps {
|
||||
field: ConfigField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function GoogleFontPicker({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: GoogleFontPickerProps) {
|
||||
const id = field.path.replace(/\./g, "-");
|
||||
const selected = normalizeGoogleFontValue(value);
|
||||
|
||||
useEffect(() => {
|
||||
injectGoogleFontsStylesheet();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{field.label}</Label>
|
||||
{field.description ? (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
) : null}
|
||||
<Select
|
||||
value={selected}
|
||||
disabled={readOnly}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
className="border-border bg-secondary/30"
|
||||
style={{ fontFamily: fontFamilyCss(selected) }}
|
||||
>
|
||||
<SelectValue placeholder="Choose a font…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[min(320px,50vh)]">
|
||||
<SelectItem value={SYSTEM_FONT_ID} style={{ fontFamily: fontFamilyCss(SYSTEM_FONT_ID) }}>
|
||||
System default
|
||||
</SelectItem>
|
||||
{GOOGLE_FONTS.map((font) => (
|
||||
<SelectItem
|
||||
key={font.id}
|
||||
value={font.id}
|
||||
style={{ fontFamily: fontFamilyCss(font.id) }}
|
||||
>
|
||||
{font.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Delete02Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface StringListEditorProps {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function StringListEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Add item…",
|
||||
}: StringListEditorProps) {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const next = [...items];
|
||||
next[index] = e.target.value;
|
||||
onChange(next);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="border-border bg-secondary/30"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onChange(items.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Icon icon={Delete02Icon} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-dashed border-border"
|
||||
onClick={() => onChange([...items, ""])}
|
||||
>
|
||||
<Icon icon={PlusSignIcon} size={16} className="mr-2" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import "emoji-picker-element";
|
||||
|
||||
interface UnicodeEmojiPanelProps {
|
||||
onPick: (unicode: string) => void;
|
||||
}
|
||||
|
||||
export function UnicodeEmojiPanel({ onPick }: UnicodeEmojiPanelProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
const picker = document.createElement("emoji-picker");
|
||||
picker.className = "unicode-emoji-picker-el";
|
||||
picker.setAttribute("data-theme", "dark");
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ unicode?: string; emoji?: { unicode?: string } }>)
|
||||
.detail;
|
||||
const unicode = detail?.unicode ?? detail?.emoji?.unicode;
|
||||
if (unicode) onPick(unicode);
|
||||
};
|
||||
|
||||
picker.addEventListener("emoji-click", handler);
|
||||
host.appendChild(picker);
|
||||
|
||||
return () => {
|
||||
picker.removeEventListener("emoji-click", handler);
|
||||
picker.remove();
|
||||
};
|
||||
}, [onPick]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="unicode-emoji-panel-host overflow-hidden rounded-lg border border-border/50"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
InformationCircleIcon,
|
||||
Logout03Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
SEVERITY_STYLES,
|
||||
type DashboardAlertPayload,
|
||||
type DashboardAlertRenderContext,
|
||||
type DashboardAlertSeverity,
|
||||
} from "@/lib/dashboard-alerts";
|
||||
|
||||
function severityIcon(severity: DashboardAlertSeverity) {
|
||||
if (severity === "info") return InformationCircleIcon;
|
||||
return AlertCircleIcon;
|
||||
}
|
||||
|
||||
interface DashboardAlertCardProps {
|
||||
alert: DashboardAlertPayload;
|
||||
context: DashboardAlertRenderContext;
|
||||
}
|
||||
|
||||
export function DashboardAlertCard({ alert, context }: DashboardAlertCardProps) {
|
||||
const styles = SEVERITY_STYLES[alert.severity];
|
||||
const extraGuilds = alert.meta?.extraGuilds ?? [];
|
||||
const guildStatus = alert.meta?.status;
|
||||
const moduleProgress = alert.meta?.progress;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className={`${styles.border} ${styles.bg} text-foreground`}
|
||||
>
|
||||
<Icon icon={severityIcon(alert.severity)} size={18} className={styles.icon} />
|
||||
<AlertTitle className={styles.title}>{alert.title}</AlertTitle>
|
||||
<AlertDescription className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>{alert.message}</p>
|
||||
|
||||
{alert.category === "guild" && guildStatus === "missing_config" ? (
|
||||
<p>
|
||||
Open{" "}
|
||||
<Link
|
||||
to="/configs?file=supportbot"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Bot config
|
||||
</Link>{" "}
|
||||
and set <strong className="text-foreground">Server (guild) ID</strong>{" "}
|
||||
(enable Developer Mode in Discord, then right-click your server → Copy
|
||||
Server ID).
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{alert.category === "modules" && moduleProgress ? (
|
||||
<p className="text-xs">
|
||||
Progress: <span className="text-foreground">{moduleProgress}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{extraGuilds.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{extraGuilds.map((g) => (
|
||||
<li
|
||||
key={g.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-background/40 px-3 py-2"
|
||||
>
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{g.name}</span>
|
||||
<span className="ml-2 font-mono text-xs">{g.id}</span>
|
||||
</span>
|
||||
{context.canLeaveGuild ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={context.leavingGuildId === g.id}
|
||||
onClick={() => void context.onLeaveGuild(g.id)}
|
||||
>
|
||||
<Icon icon={Logout03Icon} size={14} className="mr-1.5" />
|
||||
{context.leavingGuildId === g.id ? "Leaving…" : "Leave server"}
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{context.actionError ? (
|
||||
<p className="text-destructive">{context.actionError}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
{alert.action ? (
|
||||
<Button asChild size="sm" variant="secondary">
|
||||
<Link to={alert.action.href}>{alert.action.label}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{alert.category === "guild" ? (
|
||||
<p className="text-xs">
|
||||
The dashboard stays online while you fix this. Some features may be
|
||||
limited until setup is complete.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
dashboardAlertBuilder,
|
||||
type DashboardAlertPayload,
|
||||
} from "@/lib/dashboard-alerts";
|
||||
import { DashboardAlertCard } from "./dashboard-alert-card";
|
||||
|
||||
const REFRESH_MS = 15_000;
|
||||
|
||||
export function DashboardAlerts() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canLeave = hasPermission("settings.update");
|
||||
const [alerts, setAlerts] = useState<DashboardAlertPayload[]>([]);
|
||||
const [leavingGuildId, setLeavingGuildId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await dashboardAlertBuilder.loadAll();
|
||||
setAlerts(next);
|
||||
setActionError("");
|
||||
} catch {
|
||||
/* ignore — banners are best-effort */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const id = window.setInterval(() => void refresh(), REFRESH_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
async function leaveGuild(guildId: string) {
|
||||
if (!canLeave) return;
|
||||
setLeavingGuildId(guildId);
|
||||
setActionError("");
|
||||
try {
|
||||
await api.leaveGuild(guildId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Could not leave server");
|
||||
} finally {
|
||||
setLeavingGuildId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (alerts.length === 0) return null;
|
||||
|
||||
const renderContext = {
|
||||
canLeaveGuild: canLeave,
|
||||
onLeaveGuild: leaveGuild,
|
||||
leavingGuildId,
|
||||
actionError,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{alerts.map((alert) => (
|
||||
<DashboardAlertCard key={alert.id} alert={alert} context={renderContext} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DashboardAlerts } from "./dashboard-alerts";
|
||||
export { DashboardAlertCard } from "./dashboard-alert-card";
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
PlusSignIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { DashboardSearch } from "@/components/dashboard-search";
|
||||
import { useBotStats } from "@/context/BotStatsContext";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NotificationBell } from "@/components/notification-bell";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { displayDiscordName, useAuth } from "@/context/AuthContext";
|
||||
|
||||
export function DashboardHeader() {
|
||||
const { stats } = useBotStats();
|
||||
const {
|
||||
user,
|
||||
logout,
|
||||
availableGroups,
|
||||
isImpersonating,
|
||||
impersonatedGroup,
|
||||
impersonateGroup,
|
||||
stopImpersonation,
|
||||
hasPermission,
|
||||
} = useAuth();
|
||||
const canImpersonate = user?.isOwner || hasPermission("users.manage");
|
||||
|
||||
const botName = stats?.bot.username ?? "SupportBot";
|
||||
const accountName = user ? displayDiscordName(user) : botName;
|
||||
const accountAvatar = user?.avatar ?? stats?.bot.avatar;
|
||||
const inviteUrl = stats?.bot.inviteUrl;
|
||||
const dateLabel = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date());
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Impersonation Preview Mode Banner */}
|
||||
{isImpersonating && impersonatedGroup && (
|
||||
<div className="relative z-50 flex items-center justify-between gap-3 bg-gradient-to-r from-amber-500/10 via-amber-500/15 to-amber-500/10 border-b border-amber-500/30 px-4 py-2 text-xs backdrop-blur-md shadow-sm">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 text-[11px] font-bold tracking-wide uppercase border border-amber-500/30">
|
||||
<span className="size-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
Preview Mode
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-foreground font-medium">
|
||||
<span className="text-muted-foreground">Viewing dashboard as:</span>
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-secondary/80 border border-border/80">
|
||||
<span
|
||||
className="size-2 rounded-full shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: impersonatedGroup.color || "#10B981" }}
|
||||
/>
|
||||
<span className="font-semibold text-xs text-foreground">
|
||||
{impersonatedGroup.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
{availableGroups.length > 1 && (
|
||||
<Select
|
||||
value={impersonatedGroup.id}
|
||||
onValueChange={(val) => impersonateGroup(val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs bg-background/80 border-amber-500/30 text-amber-200 focus:ring-amber-500/40 w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-border bg-card">
|
||||
{availableGroups.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="size-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: g.color || "#10B981" }}
|
||||
/>
|
||||
<span>{g.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-3 text-xs bg-card border-amber-500/40 text-amber-300 hover:bg-amber-500/20 hover:text-white transition-all shadow-sm"
|
||||
onClick={stopImpersonation}
|
||||
>
|
||||
Exit Preview Mode
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="relative z-40 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1 text-muted-foreground hover:text-foreground md:hidden" />
|
||||
|
||||
<p className="hidden shrink-0 text-sm font-medium text-muted-foreground lg:block">
|
||||
{dateLabel}
|
||||
</p>
|
||||
|
||||
<DashboardSearch className="min-w-0 flex-1" />
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{inviteUrl ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden size-9 text-muted-foreground sm:inline-flex"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={inviteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Invite bot to a server"
|
||||
title="Invite bot to a server"
|
||||
>
|
||||
<Icon icon={PlusSignIcon} size={18} />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden size-9 text-muted-foreground sm:inline-flex"
|
||||
disabled
|
||||
aria-label="Invite bot to a server"
|
||||
title="Invite link available when the bot is online"
|
||||
>
|
||||
<Icon icon={PlusSignIcon} size={18} />
|
||||
</Button>
|
||||
)}
|
||||
<NotificationBell />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 gap-2 rounded-full px-2 hover:bg-secondary"
|
||||
>
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage src={accountAvatar} alt={accountName} />
|
||||
<AvatarFallback className="bg-primary/20 text-xs text-primary">
|
||||
{accountName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden max-w-[100px] truncate text-sm font-medium sm:inline">
|
||||
{accountName}
|
||||
</span>
|
||||
<Icon icon={ArrowDown01Icon} size={16} className="text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{user ? (
|
||||
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
|
||||
{user.username}
|
||||
{user.id ? ` · ${user.id}` : ""}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Search01Icon } from "@hugeicons/core-free-icons";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
groupSearchResults,
|
||||
searchDashboard,
|
||||
type SearchItem,
|
||||
} from "@/lib/search-index";
|
||||
|
||||
export function DashboardSearch({ className }: { className?: string }) {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [panelRect, setPanelRect] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
|
||||
const results = useMemo(() => searchDashboard(query, 14), [query]);
|
||||
const groups = useMemo(() => groupSearchResults(results), [results]);
|
||||
const showPanel = open && query.trim().length > 0;
|
||||
|
||||
const updatePanelRect = () => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setPanelRect({
|
||||
top: rect.bottom + 6,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showPanel) {
|
||||
setPanelRect(null);
|
||||
return;
|
||||
}
|
||||
updatePanelRect();
|
||||
window.addEventListener("resize", updatePanelRect);
|
||||
window.addEventListener("scroll", updatePanelRect, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePanelRect);
|
||||
window.removeEventListener("scroll", updatePanelRect, true);
|
||||
};
|
||||
}, [showPanel, query]);
|
||||
|
||||
useEffect(() => {
|
||||
function onPointerDown(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
containerRef.current?.contains(target) ||
|
||||
panelRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
function selectItem(item: SearchItem) {
|
||||
navigate(item.href);
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative w-full max-w-sm", className)}>
|
||||
<Icon
|
||||
icon={Search01Icon}
|
||||
size={16}
|
||||
className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Search modules, configs…"
|
||||
className="h-9 border-border bg-secondary/50 pl-9"
|
||||
role="combobox"
|
||||
aria-expanded={showPanel}
|
||||
aria-controls="dashboard-search-results"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<kbd className="pointer-events-none absolute right-2 top-2 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100 sm:flex">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</kbd>
|
||||
|
||||
{showPanel && panelRect
|
||||
? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
id="dashboard-search-results"
|
||||
className="overflow-hidden rounded-xl border border-border bg-popover shadow-lg shadow-black/20"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: panelRect.top,
|
||||
left: panelRect.left,
|
||||
width: panelRect.width,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
<Command shouldFilter={false} className="bg-transparent">
|
||||
<CommandList className="max-h-[min(360px,50vh)]">
|
||||
{results.length === 0 ? (
|
||||
<CommandEmpty className="py-8 text-muted-foreground">
|
||||
No results for “{query.trim()}”
|
||||
</CommandEmpty>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<CommandGroup key={group.category} heading={group.label}>
|
||||
{group.items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
onSelect={() => selectItem(item)}
|
||||
className="cursor-pointer gap-3 rounded-lg py-2.5 aria-selected:bg-accent"
|
||||
>
|
||||
{item.icon ? (
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-primary">
|
||||
<Icon icon={item.icon} size={16} />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.subtitle ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{item.subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { firstAllowedPath, hasPermission } from "@/lib/permissions";
|
||||
import Overview from "@/pages/Overview";
|
||||
|
||||
export function HomeRedirect() {
|
||||
const { permissions } = useAuth();
|
||||
|
||||
if (hasPermission(permissions, "overview")) {
|
||||
return <Overview />;
|
||||
}
|
||||
|
||||
return <Navigate to={firstAllowedPath(permissions)} replace />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface IconProps {
|
||||
icon: IconSvgElement;
|
||||
className?: string;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
icon,
|
||||
className,
|
||||
size = 18,
|
||||
strokeWidth = 1.75,
|
||||
}: IconProps) {
|
||||
return (
|
||||
<HugeiconsIcon
|
||||
icon={icon}
|
||||
size={size}
|
||||
className={cn("shrink-0", className)}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Cancel01Icon,
|
||||
Notification01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { useNotifications } from "@/context/NotificationsContext";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { DashboardNotification } from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatRelativeTime(ts: number) {
|
||||
const sec = Math.floor((Date.now() - ts) / 1000);
|
||||
if (sec < 60) return "Just now";
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
function typeDotClass(type: DashboardNotification["type"]) {
|
||||
switch (type) {
|
||||
case "error":
|
||||
return "bg-destructive";
|
||||
case "update":
|
||||
return "bg-amber-500";
|
||||
case "bot":
|
||||
return "bg-emerald-500";
|
||||
default:
|
||||
return "bg-primary";
|
||||
}
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const navigate = useNavigate();
|
||||
const { items, unreadCount, loading, markAllRead, dismiss, refresh } =
|
||||
useNotifications();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const onOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
void refresh();
|
||||
if (unreadCount > 0) void markAllRead();
|
||||
}
|
||||
};
|
||||
|
||||
const onSelect = (n: DashboardNotification) => {
|
||||
setOpen(false);
|
||||
if (n.href) navigate(n.href);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative size-9 text-muted-foreground"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Icon icon={Notification01Icon} size={18} />
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="absolute -right-0.5 -top-0.5 flex size-4 min-w-4 items-center justify-center rounded-full bg-destructive p-0 text-[10px] text-destructive-foreground">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<DropdownMenuLabel className="p-0 text-sm font-semibold">
|
||||
Notifications
|
||||
</DropdownMenuLabel>
|
||||
{items.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => void markAllRead()}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator className="m-0" />
|
||||
<ScrollArea className="max-h-[min(360px,50vh)]">
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No notifications yet. Bot restarts and update checks will appear
|
||||
here.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="py-1">
|
||||
{items.map((n) => (
|
||||
<li
|
||||
key={n.id}
|
||||
className={cn(
|
||||
"group flex gap-2 border-b border-border/40 px-3 py-2.5 last:border-0",
|
||||
!n.read && "bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 size-2 shrink-0 rounded-full",
|
||||
typeDotClass(n.type),
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => onSelect(n)}
|
||||
>
|
||||
<p className="text-sm font-medium leading-tight">
|
||||
{n.title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
|
||||
{n.message}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground/80">
|
||||
{formatRelativeTime(n.createdAt)}
|
||||
</p>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 opacity-0 group-hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void dismiss(n.id);
|
||||
}}
|
||||
>
|
||||
<Icon icon={Cancel01Icon} size={14} />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<DropdownMenuSeparator className="m-0" />
|
||||
<div className="p-2">
|
||||
<Button variant="ghost" size="sm" className="w-full text-xs" asChild>
|
||||
<Link to="/settings" onClick={() => setOpen(false)}>
|
||||
Open settings (updates)
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModuleStatus } from "@/components/overview/module-strip-card";
|
||||
|
||||
const BADGE_CLASS: Record<ModuleStatus, string> = {
|
||||
todo: "bg-rose-500/15 text-rose-400",
|
||||
progress: "bg-amber-500/15 text-amber-400",
|
||||
done: "bg-emerald-500/15 text-emerald-400",
|
||||
};
|
||||
|
||||
interface ActiveModuleCardProps {
|
||||
title: string;
|
||||
icon: IconSvgElement;
|
||||
href: string;
|
||||
badge?: string;
|
||||
status?: ModuleStatus;
|
||||
}
|
||||
|
||||
export function ActiveModuleCard({
|
||||
title,
|
||||
icon,
|
||||
href,
|
||||
badge = "Configured",
|
||||
status = "done",
|
||||
}: ActiveModuleCardProps) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-2xl border border-border bg-card p-4 transition-colors",
|
||||
"hover:border-primary hover:bg-secondary",
|
||||
)}
|
||||
>
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-full bg-secondary text-primary">
|
||||
<Icon icon={icon} size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold">{title}</p>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold",
|
||||
BADGE_CLASS[status],
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DonutChartProps {
|
||||
percent: number;
|
||||
size?: number;
|
||||
className?: string;
|
||||
trackClassName?: string;
|
||||
strokeClassName?: string;
|
||||
}
|
||||
|
||||
export function DonutChart({
|
||||
percent,
|
||||
size = 128,
|
||||
className,
|
||||
trackClassName = "stroke-white/20",
|
||||
strokeClassName = "stroke-white",
|
||||
}: DonutChartProps) {
|
||||
const r = 42;
|
||||
const c = 2 * Math.PI * r;
|
||||
const clamped = Math.min(100, Math.max(0, percent));
|
||||
const offset = c - (clamped / 100) * c;
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-flex", className)}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 100 100"
|
||||
className="-rotate-90"
|
||||
aria-hidden
|
||||
>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth="9"
|
||||
className={trackClassName}
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth="9"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
strokeDashoffset={offset}
|
||||
className={strokeClassName}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span className="text-2xl font-bold tabular-nums leading-none">
|
||||
{Math.round(clamped)}%
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-wide opacity-80">
|
||||
Complete
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight01Icon,
|
||||
Link01Icon,
|
||||
Settings02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ModuleStatus = "todo" | "progress" | "done";
|
||||
|
||||
const statusStyles: Record<
|
||||
ModuleStatus,
|
||||
{ dot: string; badge: string; label: string }
|
||||
> = {
|
||||
todo: {
|
||||
dot: "bg-rose-500",
|
||||
badge: "bg-rose-500/15 text-rose-400",
|
||||
label: "Needs setup",
|
||||
},
|
||||
progress: {
|
||||
dot: "bg-amber-400",
|
||||
badge: "bg-amber-500/15 text-amber-400",
|
||||
label: "In progress",
|
||||
},
|
||||
done: {
|
||||
dot: "bg-sky-400",
|
||||
badge: "bg-sky-500/15 text-sky-400",
|
||||
label: "Active",
|
||||
},
|
||||
};
|
||||
|
||||
interface ModuleStripCardProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: IconSvgElement;
|
||||
href: string;
|
||||
status: ModuleStatus;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export function ModuleStripCard({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
href,
|
||||
status,
|
||||
meta = "Open in dashboard",
|
||||
}: ModuleStripCardProps) {
|
||||
const s = statusStyles[status];
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
"group flex min-w-[280px] flex-1 flex-col gap-4 rounded-2xl border border-border bg-card p-4 transition-colors",
|
||||
"hover:border-primary hover:bg-secondary",
|
||||
"sm:min-w-[300px] lg:min-w-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className={cn("mt-1.5 size-2 shrink-0 rounded-full", s.dot)} />
|
||||
<span className="text-xs text-muted-foreground">{subtitle}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold leading-snug tracking-tight">{title}</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-2 inline-flex rounded-md px-2 py-0.5 text-[11px] font-semibold",
|
||||
s.badge,
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-border pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-secondary text-primary">
|
||||
<Icon icon={icon} size={16} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{meta}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Icon icon={Link01Icon} size={14} className="opacity-60" />
|
||||
<Icon
|
||||
icon={Settings02Icon}
|
||||
size={14}
|
||||
className="opacity-60 group-hover:text-primary"
|
||||
/>
|
||||
<Icon
|
||||
icon={ArrowRight01Icon}
|
||||
size={14}
|
||||
className="text-primary opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight01Icon,
|
||||
Download04Icon,
|
||||
Rocket01Icon,
|
||||
Settings02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import type { BotStats } from "@/api/client";
|
||||
import { formatUptime } from "@/api/client";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface OverviewAsideProps {
|
||||
stats: BotStats;
|
||||
}
|
||||
|
||||
export function OverviewAside({ stats }: OverviewAsideProps) {
|
||||
const activity = [
|
||||
{
|
||||
id: "ping",
|
||||
label: "Gateway latency",
|
||||
detail: `${stats.bot.ping}ms`,
|
||||
badge: stats.bot.ping < 200 ? "Good" : "High",
|
||||
badgeClass:
|
||||
stats.bot.ping < 200
|
||||
? "bg-emerald-500/15 text-emerald-400"
|
||||
: "bg-amber-500/15 text-amber-400",
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
label: "Bot uptime",
|
||||
detail: formatUptime(stats.bot.uptime),
|
||||
badge: "Live",
|
||||
badgeClass: "bg-primary/20 text-primary",
|
||||
},
|
||||
{
|
||||
id: "ram",
|
||||
label: "Memory usage",
|
||||
detail: `${stats.hosting.ram_used} / ${stats.hosting.ram_total}`,
|
||||
badge: `${stats.hosting.ram_percent}%`,
|
||||
badgeClass: "bg-sky-500/15 text-sky-400",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="hidden w-[min(100%,320px)] shrink-0 space-y-4 xl:block">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
Connected servers ({stats.servers})
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="size-7 text-muted-foreground">
|
||||
<span className="text-lg leading-none">+</span>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex -space-x-2">
|
||||
{Array.from({ length: Math.min(6, stats.servers) }).map((_, i) => (
|
||||
<Avatar
|
||||
key={i}
|
||||
className="size-9 border-2 border-card ring-0"
|
||||
>
|
||||
<AvatarImage src={stats.bot.avatar} alt="" />
|
||||
<AvatarFallback className="bg-primary/25 text-[10px] text-primary">
|
||||
{String(i + 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{stats.servers > 6 ? (
|
||||
<div className="flex size-9 items-center justify-center rounded-full border-2 border-card bg-secondary text-xs font-medium text-muted-foreground">
|
||||
+{stats.servers - 6}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{stats.users.toLocaleString()} members across your guilds
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-border bg-secondary">
|
||||
<CardContent className="p-5">
|
||||
<div className="space-y-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||
<Icon icon={Rocket01Icon} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Keep SupportBot up to date</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Check for releases and install updates from settings.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="sm" className="w-full">
|
||||
<Link to="/settings">
|
||||
<Icon icon={Download04Icon} size={16} className="mr-2" />
|
||||
Open settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-semibold">System activity</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="size-7 text-muted-foreground">
|
||||
<span className="text-base leading-none">⋯</span>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 p-0 pb-2">
|
||||
{activity.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-secondary/40"
|
||||
>
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage src={stats.bot.avatar} alt={stats.bot.username} />
|
||||
<AvatarFallback className="bg-secondary text-xs">
|
||||
SB
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{item.label}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{item.detail}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold ${item.badgeClass}`}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="mx-2 mt-1 w-[calc(100%-1rem)] justify-between text-xs text-muted-foreground"
|
||||
>
|
||||
<Link to="/system">
|
||||
All system details
|
||||
<Icon icon={ArrowRight01Icon} size={14} />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border bg-card">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-secondary text-primary">
|
||||
<Icon icon={Settings02Icon} size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">v{stats.bot.version}</p>
|
||||
<p className="text-xs text-muted-foreground">CPU {stats.hosting.cpu_load}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PluginCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: IconSvgElement;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function PluginCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
href,
|
||||
}: PluginCardProps) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className={cn(
|
||||
"group flex flex-col rounded-2xl border border-border bg-card p-5 transition-colors",
|
||||
"hover:border-primary hover:bg-secondary",
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex size-10 items-center justify-center rounded-xl bg-secondary text-primary">
|
||||
<Icon icon={icon} size={20} />
|
||||
</div>
|
||||
<h3 className="mb-2 font-semibold tracking-tight">{title}</h3>
|
||||
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { SparklesIcon } from "@hugeicons/core-free-icons";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBotStats } from "@/context/BotStatsContext";
|
||||
|
||||
export function PromoBanner() {
|
||||
const { stats } = useBotStats();
|
||||
const version = stats?.bot.version ?? "26.1.1";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card p-6 md:p-8">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||
<div className="max-w-lg space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Icon icon={SparklesIcon} size={16} />
|
||||
<span>SupportBot {version}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold tracking-tight md:text-3xl">
|
||||
Manage tickets, configs & transcripts
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground md:text-base">
|
||||
Configure your bot from one place. Edit settings visually, review
|
||||
ticket transcripts, and monitor live stats.
|
||||
</p>
|
||||
<Button asChild variant="secondary" className="mt-2">
|
||||
<Link to="/configs">Open settings</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden shrink-0 md:block">
|
||||
<div className="flex size-36 items-center justify-center rounded-2xl border border-border bg-secondary text-6xl">
|
||||
🤖
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { firstAllowedPath, hasPermission } from "@/lib/permissions";
|
||||
|
||||
export function RequirePermission({
|
||||
permission,
|
||||
children,
|
||||
}: {
|
||||
permission: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { loading, isAuthenticated, permissions } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center py-16 text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
if (!hasPermission(permissions, permission)) {
|
||||
return <Navigate to={firstAllowedPath(permissions)} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
export function DashboardApiSettings() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canEdit = hasPermission("settings.update");
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfigJson("api")
|
||||
.then((res) => {
|
||||
if (res.data) setConfig(res.data);
|
||||
})
|
||||
.catch(() => setMessage("Failed to load API config"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function saveConfig() {
|
||||
if (!canEdit || !config) return;
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
try {
|
||||
// Create a flat update payload for updateConfigFields
|
||||
const updates = {
|
||||
"api:API.Port": config.API?.Port,
|
||||
"api:API.EmeraldAPIKey": config.API?.EmeraldAPIKey,
|
||||
"api:API.TrustProxy": config.API?.TrustProxy,
|
||||
"api:API.OAuth.Enabled": config.API?.OAuth?.Enabled,
|
||||
"api:API.OAuth.ClientId": config.API?.OAuth?.ClientId,
|
||||
"api:API.OAuth.ClientSecret": config.API?.OAuth?.ClientSecret,
|
||||
"api:API.OAuth.RedirectUri": config.API?.OAuth?.RedirectUri,
|
||||
};
|
||||
|
||||
await api.updateConfigFields(updates);
|
||||
setMessage("API settings saved and backend restarted.");
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="animate-pulse h-32 bg-secondary/20 rounded-md" />;
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Core API Settings</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure the internal API that powers this dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>API Port</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.API?.Port || ""}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setConfig({ ...config, API: { ...config.API, Port: parseInt(e.target.value) || 3000 }})}
|
||||
className="border-border bg-secondary/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Emerald API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={config.API?.EmeraldAPIKey || ""}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setConfig({ ...config, API: { ...config.API, EmeraldAPIKey: e.target.value }})}
|
||||
className="border-border bg-secondary/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Switch
|
||||
checked={config.API?.TrustProxy || false}
|
||||
disabled={!canEdit}
|
||||
onCheckedChange={(c) => setConfig({ ...config, API: { ...config.API, TrustProxy: c }})}
|
||||
/>
|
||||
<Label className="text-sm">Trust Proxy (Enable if using Cloudflare/Nginx)</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border-t border-border pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium">OAuth Authentication</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Discord application credentials for dashboard login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<Switch
|
||||
checked={config.API?.OAuth?.Enabled || false}
|
||||
disabled={!canEdit}
|
||||
onCheckedChange={(c) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, Enabled: c } }})}
|
||||
/>
|
||||
<Label className="text-sm">Enable OAuth</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Client ID</Label>
|
||||
<Input
|
||||
value={config.API?.OAuth?.ClientId || ""}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, ClientId: e.target.value } }})}
|
||||
className="border-border bg-secondary/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Client Secret</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={config.API?.OAuth?.ClientSecret || ""}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, ClientSecret: e.target.value } }})}
|
||||
className="border-border bg-secondary/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Redirect URI</Label>
|
||||
<Input
|
||||
value={config.API?.OAuth?.RedirectUri || ""}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, RedirectUri: e.target.value } }})}
|
||||
className="border-border bg-secondary/30"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Must match the OAuth2 callback URL registered in the Discord Developer Portal exactly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit ? (
|
||||
<div className="flex items-center gap-2 border-t border-border pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void saveConfig()}
|
||||
>
|
||||
{saving ? "Saving…" : "Save API Config"}
|
||||
</Button>
|
||||
{message ? (
|
||||
<span className="text-xs text-muted-foreground">{message}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ImageUpload01Icon } from "@hugeicons/core-free-icons";
|
||||
import { api } from "@/api/client";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useBranding } from "@/context/BrandingContext";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function readFileAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error("Could not read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
interface DashboardBrandingSettingsProps {
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function DashboardDangerZone({ canEdit }: DashboardBrandingSettingsProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function resetDashboard() {
|
||||
if (!canEdit) return;
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.resetDashboard();
|
||||
setMessage(res.message || "Dashboard reset successfully.");
|
||||
setTimeout(() => {
|
||||
window.location.href = "/setup";
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Reset failed");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reset the dashboard to factory settings. This clears the configuration and returns you to the initial setup screen.
|
||||
</p>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="destructive">
|
||||
Reset Dashboard
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will reset the dashboard to setup mode.
|
||||
You will need to reconnect the Discord OAuth and reconfigure the dashboard owners.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{message && <p className="text-sm font-medium text-destructive">{message}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void resetDashboard()} disabled={saving}>
|
||||
{saving ? "Resetting..." : "Yes, reset dashboard"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettingsProps) {
|
||||
const { branding, setBrandingLocal } = useBranding();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [title, setTitle] = useState(branding.title);
|
||||
const [pageTitle, setPageTitle] = useState(branding.pageTitle);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(branding.title);
|
||||
setPageTitle(branding.pageTitle);
|
||||
}, [branding.title, branding.pageTitle]);
|
||||
|
||||
const faviconPreview =
|
||||
branding.hasFavicon && branding.faviconUrl
|
||||
? `${branding.faviconUrl}${branding.updatedAt ? `?v=${branding.updatedAt}` : ""}`
|
||||
: null;
|
||||
|
||||
async function saveText() {
|
||||
if (!canEdit) return;
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.updateBranding({ title, pageTitle });
|
||||
if (res.data) setBrandingLocal(res.data);
|
||||
setMessage(res.message || "Branding saved.");
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFaviconSelected(file: File | null) {
|
||||
if (!canEdit || !file) return;
|
||||
setUploading(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
const res = await api.uploadBrandingFavicon(dataUrl);
|
||||
if (res.data) setBrandingLocal(res.data);
|
||||
setMessage(res.message || "Favicon uploaded.");
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFavicon() {
|
||||
if (!canEdit) return;
|
||||
setUploading(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.removeBrandingFavicon();
|
||||
if (res.data) setBrandingLocal(res.data);
|
||||
setMessage(res.message || "Favicon removed.");
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Remove failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const textDirty =
|
||||
title.trim() !== branding.title || pageTitle.trim() !== branding.pageTitle;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Text Branding */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Branding Text</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sidebar title and browser tab title — saved on the server for everyone using this dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="brand-title">Sidebar title</Label>
|
||||
<Input
|
||||
id="brand-title"
|
||||
value={title}
|
||||
disabled={!canEdit}
|
||||
maxLength={64}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="border-border bg-secondary/30"
|
||||
placeholder="SupportBot"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="brand-page-title">Browser tab title</Label>
|
||||
<Input
|
||||
id="brand-page-title"
|
||||
value={pageTitle}
|
||||
disabled={!canEdit}
|
||||
maxLength={80}
|
||||
onChange={(e) => setPageTitle(e.target.value)}
|
||||
className="border-border bg-secondary/30"
|
||||
placeholder="SupportBot Dashboard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saving || !textDirty}
|
||||
onClick={() => void saveText()}
|
||||
>
|
||||
{saving ? "Saving…" : "Save titles"}
|
||||
</Button>
|
||||
{message && textDirty ? (
|
||||
<span className="text-xs text-muted-foreground">{message}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Favicon Branding */}
|
||||
<div className="space-y-4 border-t border-border pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Favicon</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PNG, ICO, SVG, JPEG, or WebP — max 512 KB. Shown in the browser tab and beside the sidebar title.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex size-16 items-center justify-center overflow-hidden rounded-lg border border-border bg-secondary/30">
|
||||
{faviconPreview ? (
|
||||
<img
|
||||
src={faviconPreview}
|
||||
alt="Favicon preview"
|
||||
className="size-full object-contain p-2"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
icon={ImageUpload01Icon}
|
||||
size={24}
|
||||
className="text-muted-foreground/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,image/webp,.ico"
|
||||
className="hidden"
|
||||
disabled={!canEdit || uploading}
|
||||
onChange={(e) => void onFaviconSelected(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
{canEdit ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{uploading ? "Uploading…" : "Upload Favicon"}
|
||||
</Button>
|
||||
{branding.hasFavicon ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={uploading}
|
||||
onClick={() => void removeFavicon()}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">View only</span>
|
||||
)}
|
||||
{!textDirty && message ? (
|
||||
<span className="text-xs text-muted-foreground">{message}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,845 @@
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowLeft01Icon,
|
||||
ArrowRight01Icon,
|
||||
BotIcon,
|
||||
CheckmarkCircle02Icon,
|
||||
Copy01Icon,
|
||||
Database01Icon,
|
||||
Key01Icon,
|
||||
Link01Icon,
|
||||
SecurityLockIcon,
|
||||
UserMultiple02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import type { ReactNode } from "react";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { FieldValidationResult, SetupFormValues } from "@/lib/setup-types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SETUP_STEP_IDS = [
|
||||
"botToken",
|
||||
"database",
|
||||
"secretKey",
|
||||
"oauth",
|
||||
"redirectUri",
|
||||
"owners",
|
||||
"emeraldApi",
|
||||
] as const;
|
||||
|
||||
export type SetupStepId = (typeof SETUP_STEP_IDS)[number];
|
||||
|
||||
export const SETUP_STEPS: {
|
||||
id: SetupStepId;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: IconSvgElement;
|
||||
}[] = [
|
||||
{
|
||||
id: "botToken",
|
||||
title: "Bot token",
|
||||
subtitle: "Connect your Discord bot",
|
||||
icon: BotIcon,
|
||||
},
|
||||
{
|
||||
id: "database",
|
||||
title: "Database",
|
||||
subtitle: "SQLite or MySQL engine",
|
||||
icon: Database01Icon,
|
||||
},
|
||||
{
|
||||
id: "secretKey",
|
||||
title: "Session secret",
|
||||
subtitle: "Secure dashboard logins",
|
||||
icon: SecurityLockIcon,
|
||||
},
|
||||
{
|
||||
id: "oauth",
|
||||
title: "Discord OAuth",
|
||||
subtitle: "Sign in with Discord",
|
||||
icon: Key01Icon,
|
||||
},
|
||||
{
|
||||
id: "redirectUri",
|
||||
title: "Redirect URL",
|
||||
subtitle: "OAuth callback address",
|
||||
icon: Link01Icon,
|
||||
},
|
||||
{
|
||||
id: "owners",
|
||||
title: "Dashboard owners",
|
||||
subtitle: "Who can access this panel",
|
||||
icon: UserMultiple02Icon,
|
||||
},
|
||||
{
|
||||
id: "emeraldApi",
|
||||
title: "Emerald API",
|
||||
subtitle: "One-click addons",
|
||||
icon: Key01Icon,
|
||||
},
|
||||
];
|
||||
|
||||
function validationKeyForStep(id: SetupStepId): string {
|
||||
if (id === "oauth") return "oauth";
|
||||
if (id === "owners") return "owners";
|
||||
return id;
|
||||
}
|
||||
|
||||
export function SetupWizardShell({
|
||||
brandingTitle,
|
||||
faviconUrl,
|
||||
faviconVersion,
|
||||
hasFavicon,
|
||||
children,
|
||||
}: {
|
||||
brandingTitle: string;
|
||||
faviconUrl?: string | null;
|
||||
faviconVersion?: number;
|
||||
hasFavicon: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-svh bg-background">
|
||||
<div className="mx-auto flex min-h-svh max-w-6xl flex-col px-4 py-6 md:px-8 md:py-10">
|
||||
<header className="mb-8 flex items-center gap-4">
|
||||
{hasFavicon && faviconUrl ? (
|
||||
<img
|
||||
src={`${faviconUrl}${faviconVersion ? `?v=${faviconVersion}` : ""}`}
|
||||
alt=""
|
||||
className="size-12 rounded-2xl border border-border object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl border border-border bg-primary text-primary-foreground">
|
||||
<Icon icon={BotIcon} size={24} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
First-time setup
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight md:text-3xl">
|
||||
{brandingTitle}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupProgress({
|
||||
currentIndex,
|
||||
validation,
|
||||
}: {
|
||||
currentIndex: number;
|
||||
validation: Record<string, FieldValidationResult>;
|
||||
}) {
|
||||
const progress = ((currentIndex + 1) / SETUP_STEPS.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Step {currentIndex + 1} of {SETUP_STEPS.length}
|
||||
</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="hidden space-y-1 md:block" aria-label="Setup steps">
|
||||
{SETUP_STEPS.map((step, index) => {
|
||||
const key = validationKeyForStep(step.id);
|
||||
const verified = validation[key]?.ok;
|
||||
const isCurrent = index === currentIndex;
|
||||
const isPast = index < currentIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-xl border px-3 py-3 transition-colors",
|
||||
isCurrent && "border-primary bg-secondary",
|
||||
!isCurrent && "border-transparent",
|
||||
isPast && !isCurrent && "opacity-80",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||
verified
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: isCurrent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{verified ? (
|
||||
<Icon icon={CheckmarkCircle02Icon} size={18} />
|
||||
) : (
|
||||
<Icon icon={step.icon} size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none",
|
||||
isCurrent && "text-foreground",
|
||||
!isCurrent && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{step.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<p className="hidden text-xs leading-relaxed text-muted-foreground md:block">
|
||||
Each step can be tested with Discord before you continue. You can move back
|
||||
anytime to change a value.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupStepCard({
|
||||
step,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
step: (typeof SETUP_STEPS)[number];
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[min(520px,70vh)] flex-col rounded-2xl border border-border bg-card">
|
||||
<div className="border-b border-border px-6 py-5 md:px-8">
|
||||
<div className="flex items-center gap-3 md:hidden">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Icon icon={step.icon} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{step.title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{step.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{step.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{step.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col px-6 py-6 md:px-8">{children}</div>
|
||||
{footer ? (
|
||||
<div className="border-t border-border px-6 py-4 md:px-8">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ValidationBanner({
|
||||
result,
|
||||
}: {
|
||||
result?: FieldValidationResult;
|
||||
}) {
|
||||
if (!result) return null;
|
||||
|
||||
if (result.ok) {
|
||||
return (
|
||||
<Alert className="border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<AlertDescription className="space-y-2">
|
||||
{result.bot ? (
|
||||
<p>
|
||||
Connected as <strong>@{result.bot.username}</strong>
|
||||
{result.applicationId ? (
|
||||
<span className="text-emerald-600/80 dark:text-emerald-400/80">
|
||||
{" "}
|
||||
· App ID {result.applicationId}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{result.users?.length ? (
|
||||
<ul className="space-y-1">
|
||||
{result.users.map((u) => (
|
||||
<li key={u.id} className="flex items-center gap-2">
|
||||
<Icon icon={CheckmarkCircle02Icon} size={14} />
|
||||
{u.globalName || u.username}
|
||||
<span className="font-mono text-xs opacity-80">{u.id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{!result.bot && !result.users?.length ? (
|
||||
<p>{result.warning || "Verified successfully."}</p>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{result.error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function BotTokenStep({
|
||||
values,
|
||||
setValues,
|
||||
validation,
|
||||
validating,
|
||||
onValidate,
|
||||
}: {
|
||||
values: SetupFormValues;
|
||||
setValues: (v: SetupFormValues) => void;
|
||||
validation: Record<string, FieldValidationResult>;
|
||||
validating: boolean;
|
||||
onValidate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Open the{" "}
|
||||
<a
|
||||
href="https://discord.com/developers/applications"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Discord Developer Portal
|
||||
</a>
|
||||
, select your application, go to <strong>Bot</strong>, and reset or copy
|
||||
the token.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="botToken">Bot token</Label>
|
||||
<Input
|
||||
id="botToken"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={values.botToken}
|
||||
onChange={(e) => setValues({ ...values, botToken: e.target.value })}
|
||||
placeholder="MTI…"
|
||||
className="h-11 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<ValidationBanner result={validation.botToken} />
|
||||
<div className="mt-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={validating || !values.botToken}
|
||||
onClick={onValidate}
|
||||
>
|
||||
{validating ? "Checking with Discord…" : "Verify bot token"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecretKeyStep({
|
||||
values,
|
||||
setValues,
|
||||
validation,
|
||||
validating,
|
||||
onValidate,
|
||||
onGenerate,
|
||||
}: {
|
||||
values: SetupFormValues;
|
||||
setValues: (v: SetupFormValues) => void;
|
||||
validation: Record<string, FieldValidationResult>;
|
||||
validating: boolean;
|
||||
onValidate: () => void;
|
||||
onGenerate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
This random string signs your login cookies. Store it like a password — do
|
||||
not commit it to public repos.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secretKey">Session secret</Label>
|
||||
<Input
|
||||
id="secretKey"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={values.secretKey}
|
||||
onChange={(e) => setValues({ ...values, secretKey: e.target.value })}
|
||||
className="h-11 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<ValidationBanner result={validation.secretKey} />
|
||||
<div className="mt-auto flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={onGenerate}>
|
||||
Generate secure secret
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={validating || !values.secretKey}
|
||||
onClick={onValidate}
|
||||
>
|
||||
{validating ? "Checking…" : "Validate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OAuthStep({
|
||||
values,
|
||||
setValues,
|
||||
validation,
|
||||
validating,
|
||||
onValidate,
|
||||
}: {
|
||||
values: SetupFormValues;
|
||||
setValues: (v: SetupFormValues) => void;
|
||||
validation: Record<string, FieldValidationResult>;
|
||||
validating: boolean;
|
||||
onValidate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Use the <strong>same application</strong> as your bot. Under OAuth2, copy
|
||||
the Client ID and Client Secret.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="clientId">Client ID</Label>
|
||||
<Input
|
||||
id="clientId"
|
||||
value={values.clientId}
|
||||
onChange={(e) => setValues({ ...values, clientId: e.target.value })}
|
||||
className="h-11 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="clientSecret">Client Secret</Label>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={values.clientSecret}
|
||||
onChange={(e) =>
|
||||
setValues({ ...values, clientSecret: e.target.value })
|
||||
}
|
||||
className="h-11 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ValidationBanner result={validation.oauth} />
|
||||
<div className="mt-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={validating || !values.clientId || !values.clientSecret}
|
||||
onClick={onValidate}
|
||||
>
|
||||
{validating ? "Checking with Discord…" : "Verify OAuth credentials"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RedirectStep({
|
||||
values,
|
||||
setValues,
|
||||
validation,
|
||||
}: {
|
||||
values: SetupFormValues;
|
||||
setValues: (v: SetupFormValues) => void;
|
||||
validation: Record<string, FieldValidationResult>;
|
||||
}) {
|
||||
async function copyUri() {
|
||||
if (!values.redirectUri) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(values.redirectUri);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Add this exact URL under <strong>OAuth2 → Redirects</strong> in the
|
||||
Developer Portal. It must match character-for-character.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="redirectUri">Redirect URI</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="redirectUri"
|
||||
value={values.redirectUri}
|
||||
onChange={(e) =>
|
||||
setValues({ ...values, redirectUri: e.target.value })
|
||||
}
|
||||
className="h-11 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11 shrink-0"
|
||||
onClick={() => void copyUri()}
|
||||
title="Copy URL"
|
||||
>
|
||||
<Icon icon={Copy01Icon} size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{validation.redirectUri && !validation.redirectUri.ok ? (
|
||||
<ValidationBanner result={validation.redirectUri} />
|
||||
) : (
|
||||
<Alert className="border-border bg-secondary">
|
||||
<AlertDescription className="text-sm text-muted-foreground">
|
||||
We pre-filled this from your current dashboard URL. If you access the
|
||||
panel from a different domain later, update this and add the new URL in
|
||||
Discord too.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OwnersStep({
|
||||
values,
|
||||
setValues,
|
||||
validation,
|
||||
validating,
|
||||
onValidate,
|
||||
}: {
|
||||
values: SetupFormValues;
|
||||
setValues: (v: SetupFormValues) => void;
|
||||
validation: Record<string, FieldValidationResult>;
|
||||
validating: boolean;
|
||||
onValidate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Enable <strong>Developer Mode</strong> in Discord (Settings → Advanced),
|
||||
then right-click your profile and <strong>Copy User ID</strong>. Add one ID
|
||||
per line.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="owners">Owner user IDs</Label>
|
||||
<Textarea
|
||||
id="owners"
|
||||
value={values.ownerUserIds}
|
||||
onChange={(e) => setValues({ ...values, ownerUserIds: e.target.value })}
|
||||
placeholder={"829112572816130058\n987654321098765432"}
|
||||
className="min-h-[120px] font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<ValidationBanner result={validation.owners} />
|
||||
<div className="mt-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={validating || !values.ownerUserIds.trim()}
|
||||
onClick={onValidate}
|
||||
>
|
||||
{validating ? "Checking with Discord…" : "Verify owner IDs"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupCompleteScreen({ onContinue }: { onContinue: () => void }) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-lg flex-1 flex-col justify-center py-8">
|
||||
<div className="rounded-2xl border border-border bg-card px-6 py-10 text-center md:px-10">
|
||||
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Icon icon={CheckmarkCircle02Icon} size={40} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Setup complete</h2>
|
||||
<p className="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||
Your configuration has been saved. Restart the server for changes to take
|
||||
effect — stop the running process, then start it again (for example{" "}
|
||||
<code className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
npm start
|
||||
</code>
|
||||
).
|
||||
</p>
|
||||
<Alert className="mt-6 text-left">
|
||||
<AlertDescription>
|
||||
After restarting, sign in with Discord to open the dashboard.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button type="button" size="lg" className="mt-8 min-w-[220px]" onClick={onContinue}>
|
||||
Continue to sign in
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupNav({
|
||||
currentIndex,
|
||||
validating,
|
||||
saving,
|
||||
onBack,
|
||||
onNext,
|
||||
onFinish,
|
||||
}: {
|
||||
currentIndex: number;
|
||||
validating: boolean;
|
||||
saving: boolean;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
const isLast = currentIndex === SETUP_STEPS.length - 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={currentIndex === 0 || saving}
|
||||
onClick={onBack}
|
||||
>
|
||||
<Icon icon={ArrowLeft01Icon} size={18} className="mr-1" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-end gap-2">
|
||||
{isLast ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
disabled={saving || validating}
|
||||
onClick={onFinish}
|
||||
className="min-w-[200px]"
|
||||
>
|
||||
{saving ? "Saving…" : "Finish setup"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
disabled={validating || saving}
|
||||
onClick={onNext}
|
||||
>
|
||||
Continue
|
||||
<Icon icon={ArrowRight01Icon} size={18} className="ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmeraldApiKeyStep({
|
||||
values,
|
||||
setValues,
|
||||
validation,
|
||||
validating,
|
||||
onValidate,
|
||||
}: {
|
||||
values: SetupFormValues;
|
||||
setValues: (v: SetupFormValues) => void;
|
||||
validation: Record<string, FieldValidationResult>;
|
||||
validating: boolean;
|
||||
onValidate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-5">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
An Emerald Services API key is optional, but required to use the one-click addon installer in the dashboard. You can get yours from the{" "}
|
||||
<a
|
||||
href="https://emeraldsrv.dev"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Emerald Services Marketplace
|
||||
</a>.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emeraldApiKey">Emerald API key (Optional)</Label>
|
||||
<Input
|
||||
id="emeraldApiKey"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={values.emeraldApiKey}
|
||||
onChange={(e) => setValues({ ...values, emeraldApiKey: e.target.value })}
|
||||
placeholder="em_..."
|
||||
className="h-11 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<ValidationBanner result={validation.emeraldApi} />
|
||||
<div className="mt-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={validating}
|
||||
onClick={onValidate}
|
||||
>
|
||||
{validating ? "Checking…" : (values.emeraldApiKey ? "Verify key" : "Skip step")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabaseStep({
|
||||
values,
|
||||
setValues,
|
||||
validation,
|
||||
validating,
|
||||
onValidate,
|
||||
}: {
|
||||
values: SetupFormValues;
|
||||
setValues: React.Dispatch<React.SetStateAction<SetupFormValues>>;
|
||||
validation: Record<string, FieldValidationResult>;
|
||||
validating: boolean;
|
||||
onValidate: () => void;
|
||||
}) {
|
||||
const result = validation.database;
|
||||
const isMysql = values.dbDriver === "mysql";
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Choose your database storage engine. Select <strong>SQLite</strong> for instant zero-config local storage, or <strong>MySQL / MariaDB</strong> for external host databases.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div
|
||||
onClick={() => setValues((v) => ({ ...v, dbDriver: "sqlite" }))}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-xl border p-4 transition-all hover:border-primary",
|
||||
values.dbDriver === "sqlite"
|
||||
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
: "border-border bg-card",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-foreground">SQLite</span>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-xs font-medium text-emerald-500">
|
||||
Zero Config
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Single-file SQL database stored locally at <code className="font-mono">./Data/supportbot.db</code>. Zero extra setup required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setValues((v) => ({ ...v, dbDriver: "mysql" }))}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-xl border p-4 transition-all hover:border-primary",
|
||||
values.dbDriver === "mysql"
|
||||
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
: "border-border bg-card",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-foreground">MySQL / MariaDB</span>
|
||||
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-500">
|
||||
External Database
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect to a remote or local MySQL database server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMysql && (
|
||||
<div className="space-y-4 rounded-xl border border-border bg-card/50 p-4">
|
||||
<h4 className="text-sm font-semibold text-foreground">MySQL Connection Settings</h4>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="dbHost">Database Host</Label>
|
||||
<Input
|
||||
id="dbHost"
|
||||
placeholder="127.0.0.1 or localhost"
|
||||
value={values.dbHost}
|
||||
onChange={(e) => setValues((v) => ({ ...v, dbHost: e.target.value }))}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dbPort">Port</Label>
|
||||
<Input
|
||||
id="dbPort"
|
||||
placeholder="3306"
|
||||
value={values.dbPort}
|
||||
onChange={(e) => setValues((v) => ({ ...v, dbPort: e.target.value }))}
|
||||
className="h-11 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dbDatabase">Database Name</Label>
|
||||
<Input
|
||||
id="dbDatabase"
|
||||
placeholder="supportbot"
|
||||
value={values.dbDatabase}
|
||||
onChange={(e) => setValues((v) => ({ ...v, dbDatabase: e.target.value }))}
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dbUser">Database User</Label>
|
||||
<Input
|
||||
id="dbUser"
|
||||
placeholder="root"
|
||||
value={values.dbUser}
|
||||
onChange={(e) => setValues((v) => ({ ...v, dbUser: e.target.value }))}
|
||||
className="h-11 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dbPassword">Password</Label>
|
||||
<Input
|
||||
id="dbPassword"
|
||||
type="password"
|
||||
placeholder="••••••••••••"
|
||||
value={values.dbPassword}
|
||||
onChange={(e) => setValues((v) => ({ ...v, dbPassword: e.target.value }))}
|
||||
className="h-11 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ValidationBanner result={result} />
|
||||
|
||||
<div className="mt-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={validating}
|
||||
onClick={onValidate}
|
||||
>
|
||||
{validating ? "Testing Connection..." : "Test Connection & Validate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FloppyDiskIcon } from "@hugeicons/core-free-icons";
|
||||
import { api } from "@/api/client";
|
||||
import { ConfigFieldInput } from "@/components/config/config-field";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getByPath, setByPath } from "@/lib/config-utils";
|
||||
import { transcriptTemplateSchema } from "@/lib/transcript-template-schema";
|
||||
import { normalizeGoogleFontValue } from "@/lib/google-fonts";
|
||||
import type { TranscriptTemplate } from "@/lib/transcript-template-types";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PREVIEW_DEBOUNCE_MS = 450;
|
||||
|
||||
function canEditTemplate(role: string | undefined, hasTranscripts: boolean) {
|
||||
return hasTranscripts && role !== "viewer";
|
||||
}
|
||||
|
||||
export function TranscriptAppearanceEditor() {
|
||||
const { user, hasPermission } = useAuth();
|
||||
const readOnly = !canEditTemplate(user?.role, hasPermission("transcripts"));
|
||||
|
||||
const [data, setData] = useState<TranscriptTemplate | null>(null);
|
||||
const [original, setOriginal] = useState<TranscriptTemplate | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [previewHtml, setPreviewHtml] = useState("");
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.getTranscriptTemplate();
|
||||
const tpl = (res.data ?? null) as TranscriptTemplate | null;
|
||||
if (tpl) {
|
||||
const normalized = {
|
||||
...tpl,
|
||||
Typography: {
|
||||
...tpl.Typography,
|
||||
FontFamily: normalizeGoogleFontValue(tpl.Typography?.FontFamily),
|
||||
},
|
||||
};
|
||||
setData(normalized);
|
||||
setOriginal(structuredClone(normalized));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load template");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const refreshPreview = useCallback(async (tpl: TranscriptTemplate) => {
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const html = await api.previewTranscriptTemplate(tpl);
|
||||
setPreviewHtml(html);
|
||||
} catch {
|
||||
setPreviewHtml(
|
||||
"<!DOCTYPE html><body style='padding:2rem;font-family:sans-serif'><p>Preview failed to load.</p></body>",
|
||||
);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void refreshPreview(data);
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [data, refreshPreview]);
|
||||
|
||||
function patch(path: string, value: unknown) {
|
||||
setData((prev) =>
|
||||
prev ? (setByPath(prev as Record<string, unknown>, path, value) as TranscriptTemplate) : prev,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (readOnly || !data) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.saveTranscriptTemplate(data);
|
||||
setMessage(res.message || "Saved.");
|
||||
setOriginal(structuredClone(data));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const dirty =
|
||||
data && original && JSON.stringify(data) !== JSON.stringify(original);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Skeleton className="h-96 rounded-xl" />
|
||||
<Skeleton className="h-96 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error || "Failed to load template"}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize how new ticket transcripts look. Existing saved transcripts are not
|
||||
changed.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{dirty ? (
|
||||
<span className="text-xs text-amber-500">Unsaved changes</span>
|
||||
) : null}
|
||||
{!readOnly ? (
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
|
||||
<Icon icon={FloppyDiskIcon} size={16} className="mr-1.5" />
|
||||
{saving ? "Saving…" : "Save appearance"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">View only</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{message ? (
|
||||
<Alert className="border-primary/30 bg-primary/10 text-primary">
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 gap-4 xl:grid-cols-[minmax(320px,420px)_1fr]">
|
||||
<ScrollableEditor data={data} readOnly={readOnly} onPatch={patch} />
|
||||
|
||||
<Card className="flex min-h-[520px] flex-col overflow-hidden border-border bg-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Live preview</CardTitle>
|
||||
<CardDescription>
|
||||
Sample ticket with your current settings
|
||||
{previewLoading ? " · updating…" : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 p-4 pt-0">
|
||||
<iframe
|
||||
title="Transcript template preview"
|
||||
srcDoc={previewHtml}
|
||||
className={cn(
|
||||
"h-[min(640px,calc(100svh-14rem))] w-full rounded-lg border border-border bg-background",
|
||||
previewLoading && "opacity-60",
|
||||
)}
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollableEditor({
|
||||
data,
|
||||
readOnly,
|
||||
onPatch,
|
||||
}: {
|
||||
data: TranscriptTemplate;
|
||||
readOnly: boolean;
|
||||
onPatch: (path: string, value: unknown) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="max-h-[min(720px,calc(100svh-12rem))] space-y-4 overflow-y-auto pr-1">
|
||||
{transcriptTemplateSchema.map((section) => (
|
||||
<Card key={section.title} className="border-border bg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||
{section.description ? (
|
||||
<CardDescription>{section.description}</CardDescription>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
{section.fields.map((field) => (
|
||||
<div
|
||||
key={field.path}
|
||||
className={
|
||||
field.type === "textarea" ? "sm:col-span-2" : ""
|
||||
}
|
||||
>
|
||||
<ConfigFieldInput
|
||||
field={field}
|
||||
value={getByPath(data as Record<string, unknown>, field.path)}
|
||||
readOnly={readOnly}
|
||||
onChange={(v) => onPatch(field.path, v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FloppyDiskIcon, RefreshIcon } from "@hugeicons/core-free-icons";
|
||||
import { api } from "@/api/client";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TranscriptSettingsState {
|
||||
autoDelete: { enabled: boolean; afterDays: number };
|
||||
publicAccess: { enabled: boolean };
|
||||
}
|
||||
|
||||
function canManageTranscripts(role: string | undefined, hasTranscripts: boolean) {
|
||||
return hasTranscripts && role !== "viewer";
|
||||
}
|
||||
|
||||
export function TranscriptSettingsPanel() {
|
||||
const { user, hasPermission } = useAuth();
|
||||
const readOnly = !canManageTranscripts(user?.role, hasPermission("transcripts"));
|
||||
|
||||
const [settings, setSettings] = useState<TranscriptSettingsState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [purging, setPurging] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.getTranscriptSettings();
|
||||
const data = res.data;
|
||||
if (data) {
|
||||
setSettings({
|
||||
autoDelete: {
|
||||
enabled: Boolean(data.autoDelete?.enabled),
|
||||
afterDays: data.autoDelete?.afterDays ?? 30,
|
||||
},
|
||||
publicAccess: {
|
||||
enabled: Boolean(data.publicAccess?.enabled),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load settings");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!settings || readOnly) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.updateTranscriptSettings(settings);
|
||||
if (res.data) {
|
||||
setSettings({
|
||||
autoDelete: {
|
||||
enabled: res.data.autoDelete.enabled,
|
||||
afterDays: res.data.autoDelete.afterDays,
|
||||
},
|
||||
publicAccess: {
|
||||
enabled: res.data.publicAccess.enabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
setMessage(res.message ?? "Settings saved.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePurge() {
|
||||
if (readOnly) return;
|
||||
setPurging(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await api.purgeTranscripts();
|
||||
setMessage(
|
||||
res.message ??
|
||||
(res.data?.skipped
|
||||
? "Auto-delete is disabled."
|
||||
: `Removed ${res.data?.deleted ?? 0} transcript(s).`),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Purge failed");
|
||||
} finally {
|
||||
setPurging(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>Could not load transcript settings.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
{readOnly ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You have view-only access. An admin or editor can change these settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{message ? (
|
||||
<Alert>
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Auto-delete</CardTitle>
|
||||
<CardDescription>
|
||||
Remove transcript files automatically after they reach a certain age. The
|
||||
server checks hourly and whenever you open the library.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 size-4 rounded border-border accent-primary"
|
||||
checked={settings.autoDelete.enabled}
|
||||
disabled={readOnly}
|
||||
onChange={(e) =>
|
||||
setSettings((s) =>
|
||||
s
|
||||
? {
|
||||
...s,
|
||||
autoDelete: { ...s.autoDelete, enabled: e.target.checked },
|
||||
}
|
||||
: s,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">Enable auto-delete</span>
|
||||
<span className="mt-0.5 block text-muted-foreground">
|
||||
Deletes HTML files in Data/Transcripts based on last modified time.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="transcript-after-days">
|
||||
Delete after (days)
|
||||
</label>
|
||||
<Input
|
||||
id="transcript-after-days"
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
disabled={readOnly || !settings.autoDelete.enabled}
|
||||
value={settings.autoDelete.afterDays}
|
||||
onChange={(e) =>
|
||||
setSettings((s) =>
|
||||
s
|
||||
? {
|
||||
...s,
|
||||
autoDelete: {
|
||||
...s.autoDelete,
|
||||
afterDays: Math.min(
|
||||
3650,
|
||||
Math.max(1, parseInt(e.target.value, 10) || 1),
|
||||
),
|
||||
},
|
||||
}
|
||||
: s,
|
||||
)
|
||||
}
|
||||
className="max-w-[8rem]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={readOnly || purging}
|
||||
onClick={() => void handlePurge()}
|
||||
>
|
||||
<Icon icon={RefreshIcon} size={16} className={cn(purging && "animate-spin")} />
|
||||
Run cleanup now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Public transcripts</CardTitle>
|
||||
<CardDescription>
|
||||
When enabled, anyone with the link can view a transcript at{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">
|
||||
{typeof window !== "undefined"
|
||||
? `${window.location.origin}/transcripts/:id`
|
||||
: "/transcripts/:id"}
|
||||
</code>{" "}
|
||||
without signing in. Channel IDs are numeric but not secret—only enable if
|
||||
you are comfortable sharing links.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 size-4 rounded border-border accent-primary"
|
||||
checked={settings.publicAccess.enabled}
|
||||
disabled={readOnly}
|
||||
onChange={(e) =>
|
||||
setSettings((s) =>
|
||||
s
|
||||
? {
|
||||
...s,
|
||||
publicAccess: { enabled: e.target.checked },
|
||||
}
|
||||
: s,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">Allow public transcript URLs</span>
|
||||
<span className="mt-0.5 block text-muted-foreground">
|
||||
Dashboard preview and download still require login.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!readOnly ? (
|
||||
<Button type="button" disabled={saving} onClick={() => void handleSave()}>
|
||||
<Icon icon={FloppyDiskIcon} size={16} />
|
||||
{saving ? "Saving…" : "Save settings"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card text-card-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("grid place-content-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,113 @@
|
||||
import { forwardRef, useMemo, useState } from "react";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isValidHex, normalizeHex } from "@/lib/normalize-hex";
|
||||
import { useForwardedRef } from "@/hooks/use-forwarded-ref";
|
||||
import type { ButtonProps } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
const ColorPicker = forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<ButtonProps, "value" | "onChange" | "onBlur"> & ColorPickerProps
|
||||
>(
|
||||
(
|
||||
{ disabled, value, onChange, onBlur, name, className, size, ...props },
|
||||
forwardedRef,
|
||||
) => {
|
||||
const ref = useForwardedRef(forwardedRef);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const parsedValue = useMemo(
|
||||
() => normalizeHex(value, "#ffffff"),
|
||||
[value],
|
||||
);
|
||||
|
||||
const displayHex = open && draft ? draft : parsedValue;
|
||||
|
||||
function commitHex(raw: string) {
|
||||
if (isValidHex(raw)) {
|
||||
onChange(normalizeHex(raw));
|
||||
}
|
||||
setDraft("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) commitHex(draft || parsedValue);
|
||||
setOpen(next);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild disabled={disabled} onBlur={onBlur}>
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
className={cn(
|
||||
"size-10 shrink-0 overflow-hidden rounded-md border border-border p-0 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
name={name}
|
||||
onClick={() => setOpen(true)}
|
||||
size={size ?? "icon"}
|
||||
style={{ backgroundColor: parsedValue }}
|
||||
variant="outline"
|
||||
aria-label="Pick colour"
|
||||
>
|
||||
<span className="sr-only">{parsedValue}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto border-border bg-card p-3"
|
||||
align="start"
|
||||
>
|
||||
<HexColorPicker
|
||||
className="color-picker-swatch"
|
||||
color={parsedValue}
|
||||
onChange={(hex) => {
|
||||
setDraft("");
|
||||
onChange(normalizeHex(hex));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
ref={ref}
|
||||
maxLength={7}
|
||||
value={displayHex}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setDraft(v);
|
||||
if (isValidHex(v)) {
|
||||
onChange(normalizeHex(v));
|
||||
}
|
||||
}}
|
||||
onBlur={() => commitHex(draft || parsedValue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
commitHex(draft || parsedValue);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
className="mt-3 border-border bg-secondary/30 font-mono text-sm uppercase"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
);
|
||||
ColorPicker.displayName = "ColorPicker";
|
||||
|
||||
export { ColorPicker };
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2 border-l border-l-transparent p-px",
|
||||
orientation === "horizontal" &&
|
||||
"h-2 flex-col border-t border-t-transparent p-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[hsl(240_4%_32%)] transition-colors hover:bg-[hsl(240_4%_44%)]" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeft } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((open) => !open)
|
||||
: setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
SidebarProvider.displayName = "SidebarProvider"
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Sidebar.displayName = "Sidebar"
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
SidebarTrigger.displayName = "SidebarTrigger"
|
||||
|
||||
const SidebarRail = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarRail.displayName = "SidebarRail"
|
||||
|
||||
const SidebarInset = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"main">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarInset.displayName = "SidebarInset"
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarInput.displayName = "SidebarInput"
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarHeader.displayName = "SidebarHeader"
|
||||
|
||||
const SidebarFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarFooter.displayName = "SidebarFooter"
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarSeparator.displayName = "SidebarSeparator"
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarContent.displayName = "SidebarContent"
|
||||
|
||||
const SidebarGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroup.displayName = "SidebarGroup"
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction"
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent"
|
||||
|
||||
const SidebarMenu = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenu.displayName = "SidebarMenu"
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
)
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton"
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction"
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-[--skeleton-width] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub"
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
function Toaster({ ...props }: ToasterProps) {
|
||||
return (
|
||||
<Sonner
|
||||
theme="dark"
|
||||
position="bottom-right"
|
||||
richColors
|
||||
closeButton
|
||||
style={{ fontFamily: '"Poppins", system-ui, sans-serif' }}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toaster };
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { api } from "../api/client";
|
||||
import {
|
||||
canEditConfig,
|
||||
canViewConfig,
|
||||
displayDiscordName,
|
||||
hasPermission,
|
||||
type DashboardGroup,
|
||||
type DashboardPermissions,
|
||||
type DashboardUser,
|
||||
} from "../lib/permissions";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: DashboardUser | null;
|
||||
permissions: DashboardPermissions | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
availableGroups: DashboardGroup[];
|
||||
isImpersonating: boolean;
|
||||
impersonatedGroup: DashboardGroup | null;
|
||||
impersonateGroup: (groupId: string) => void;
|
||||
stopImpersonation: () => void;
|
||||
loginWithDiscord: () => void;
|
||||
logout: () => Promise<void>;
|
||||
refreshSession: () => Promise<boolean>;
|
||||
hasPermission: (key: string) => boolean;
|
||||
canViewConfig: (file: string) => boolean;
|
||||
canEditConfig: (file: string) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "nexus_impersonate_group";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<DashboardUser | null>(null);
|
||||
const [groups, setGroups] = useState<DashboardGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [impersonatedGroupId, setImpersonatedGroupId] = useState<string | null>(
|
||||
() => (typeof window !== "undefined" ? sessionStorage.getItem(STORAGE_KEY) : null)
|
||||
);
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
try {
|
||||
const [userRes, groupsRes] = await Promise.all([
|
||||
api.me(),
|
||||
api.listGroups().catch(() => ({ data: { groups: [] } })),
|
||||
]);
|
||||
|
||||
if (userRes.data) {
|
||||
setUser(userRes.data);
|
||||
if (groupsRes.data?.groups) {
|
||||
setGroups(groupsRes.data.groups);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
setUser(null);
|
||||
return false;
|
||||
} catch {
|
||||
setUser(null);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSession().finally(() => setLoading(false));
|
||||
}, [refreshSession]);
|
||||
|
||||
const loginWithDiscord = useCallback(() => {
|
||||
window.location.href = "/api/auth/discord";
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
if (typeof window !== "undefined") sessionStorage.removeItem(STORAGE_KEY);
|
||||
setImpersonatedGroupId(null);
|
||||
await api.logout();
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const impersonateGroup = useCallback((groupId: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem(STORAGE_KEY, groupId);
|
||||
}
|
||||
setImpersonatedGroupId(groupId);
|
||||
}, []);
|
||||
|
||||
const stopImpersonation = useCallback(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
setImpersonatedGroupId(null);
|
||||
}, []);
|
||||
|
||||
const impersonatedGroup = useMemo(
|
||||
() => groups.find((g) => g.id === impersonatedGroupId) || null,
|
||||
[groups, impersonatedGroupId]
|
||||
);
|
||||
|
||||
const isImpersonating = Boolean(impersonatedGroupId && impersonatedGroup);
|
||||
|
||||
// Effective permissions: use impersonated group's permissions if active, else real user permissions
|
||||
const permissions = useMemo(() => {
|
||||
if (isImpersonating && impersonatedGroup) {
|
||||
return impersonatedGroup.permissions;
|
||||
}
|
||||
return user?.permissions ?? null;
|
||||
}, [isImpersonating, impersonatedGroup, user]);
|
||||
|
||||
const checkPermission = useCallback(
|
||||
(key: string) => hasPermission(permissions, key),
|
||||
[permissions]
|
||||
);
|
||||
|
||||
const checkViewConfig = useCallback(
|
||||
(file: string) => canViewConfig(permissions, file),
|
||||
[permissions]
|
||||
);
|
||||
|
||||
const checkEditConfig = useCallback(
|
||||
(file: string) => canEditConfig(permissions, file),
|
||||
[permissions]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
permissions,
|
||||
isAuthenticated: !!user,
|
||||
loading,
|
||||
availableGroups: groups,
|
||||
isImpersonating,
|
||||
impersonatedGroup,
|
||||
impersonateGroup,
|
||||
stopImpersonation,
|
||||
loginWithDiscord,
|
||||
logout,
|
||||
refreshSession,
|
||||
hasPermission: checkPermission,
|
||||
canViewConfig: checkViewConfig,
|
||||
canEditConfig: checkEditConfig,
|
||||
}),
|
||||
[
|
||||
user,
|
||||
permissions,
|
||||
loading,
|
||||
groups,
|
||||
isImpersonating,
|
||||
impersonatedGroup,
|
||||
impersonateGroup,
|
||||
stopImpersonation,
|
||||
loginWithDiscord,
|
||||
logout,
|
||||
refreshSession,
|
||||
checkPermission,
|
||||
checkViewConfig,
|
||||
checkEditConfig,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { displayDiscordName };
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { api, type BotStats } from "@/api/client";
|
||||
|
||||
interface BotStatsContextValue {
|
||||
stats: BotStats | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const BotStatsContext = createContext<BotStatsContextValue | null>(null);
|
||||
|
||||
export function BotStatsProvider({ children }: { children: ReactNode }) {
|
||||
const [stats, setStats] = useState<BotStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.stats();
|
||||
if (res.data) {
|
||||
setStats(res.data);
|
||||
setError("");
|
||||
} else if (res.error) {
|
||||
setError(res.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load stats");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const id = setInterval(refresh, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ stats, loading, error, refresh }),
|
||||
[stats, loading, error, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<BotStatsContext.Provider value={value}>{children}</BotStatsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBotStats() {
|
||||
const ctx = useContext(BotStatsContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useBotStats must be used within BotStatsProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { applyDashboardBranding } from "@/lib/apply-dashboard-branding";
|
||||
import { DEFAULT_BRANDING, type DashboardBranding } from "@/lib/branding-types";
|
||||
|
||||
interface BrandingContextValue {
|
||||
branding: DashboardBranding;
|
||||
loading: boolean;
|
||||
refreshBranding: () => Promise<void>;
|
||||
setBrandingLocal: (next: DashboardBranding) => void;
|
||||
}
|
||||
|
||||
const BrandingContext = createContext<BrandingContextValue | null>(null);
|
||||
|
||||
export function BrandingProvider({ children }: { children: ReactNode }) {
|
||||
const [branding, setBranding] = useState<DashboardBranding>(DEFAULT_BRANDING);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refreshBranding = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.getBranding();
|
||||
if (res.data) {
|
||||
setBranding(res.data);
|
||||
applyDashboardBranding(res.data);
|
||||
}
|
||||
} catch {
|
||||
setBranding(DEFAULT_BRANDING);
|
||||
applyDashboardBranding(DEFAULT_BRANDING);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshBranding();
|
||||
}, [refreshBranding]);
|
||||
|
||||
const setBrandingLocal = useCallback((next: DashboardBranding) => {
|
||||
setBranding(next);
|
||||
applyDashboardBranding(next);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
branding,
|
||||
loading,
|
||||
refreshBranding,
|
||||
setBrandingLocal,
|
||||
}),
|
||||
[branding, loading, refreshBranding, setBrandingLocal],
|
||||
);
|
||||
|
||||
return (
|
||||
<BrandingContext.Provider value={value}>{children}</BrandingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBranding() {
|
||||
const ctx = useContext(BrandingContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useBranding must be used within BrandingProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
api,
|
||||
type DashboardNotification,
|
||||
} from "@/api/client";
|
||||
|
||||
interface NotificationsContextValue {
|
||||
items: DashboardNotification[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
markAllRead: () => Promise<void>;
|
||||
dismiss: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const NotificationsContext = createContext<NotificationsContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function NotificationsProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<DashboardNotification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.getNotifications();
|
||||
if (res.data) {
|
||||
setItems(res.data.items);
|
||||
setUnreadCount(res.data.unreadCount);
|
||||
}
|
||||
} catch {
|
||||
/* ignore when logged out or API down */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAllRead = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.markNotificationsRead("all");
|
||||
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
setUnreadCount(res.data?.unreadCount ?? 0);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const res = await api.dismissNotification(id);
|
||||
setItems((prev) => prev.filter((n) => n.id !== id));
|
||||
setUnreadCount(res.data?.unreadCount ?? 0);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const id = setInterval(refresh, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
items,
|
||||
unreadCount,
|
||||
loading,
|
||||
refresh,
|
||||
markAllRead,
|
||||
dismiss,
|
||||
}),
|
||||
[items, unreadCount, loading, refresh, markAllRead, dismiss],
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationsContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const ctx = useContext(NotificationsContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNotifications must be used within NotificationsProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { api } from "@/api/client";
|
||||
import type { SetupStatus } from "@/lib/setup-types";
|
||||
|
||||
interface SetupContextValue {
|
||||
status: SetupStatus | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
complete: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SetupContext = createContext<SetupContextValue | null>(null);
|
||||
|
||||
export function SetupProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<SetupStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.getSetupStatus();
|
||||
setStatus(res.data ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not load setup status");
|
||||
setStatus(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().finally(() => setLoading(false));
|
||||
}, [refresh]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
status,
|
||||
loading,
|
||||
error,
|
||||
complete: Boolean(status?.complete),
|
||||
refresh,
|
||||
}),
|
||||
[status, loading, error, refresh],
|
||||
);
|
||||
|
||||
return <SetupContext.Provider value={value}>{children}</SetupContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSetup() {
|
||||
const ctx = useContext(SetupContext);
|
||||
if (!ctx) throw new Error("useSetup must be used within SetupProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
export function useAddonConfigs() {
|
||||
const location = useLocation();
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.listAddonConfigs();
|
||||
setFiles(res.data ?? []);
|
||||
} catch {
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh, location.pathname]);
|
||||
|
||||
return { files, loading, refresh };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useRef, type ForwardedRef } from "react";
|
||||
|
||||
export function useForwardedRef<T>(forwardedRef: ForwardedRef<T>) {
|
||||
const innerRef = useRef<T>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!forwardedRef) return;
|
||||
if (typeof forwardedRef === "function") {
|
||||
forwardedRef(innerRef.current);
|
||||
} else {
|
||||
forwardedRef.current = innerRef.current;
|
||||
}
|
||||
});
|
||||
|
||||
return innerRef;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface StreamMetrics {
|
||||
timestamp: number;
|
||||
ping: number;
|
||||
ram_percent: number;
|
||||
ram_used_mb: number;
|
||||
ram_total_mb: number;
|
||||
cpu_load: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface StreamLogEntry {
|
||||
type: "Output" | "Warn" | "Error";
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface StreamTicketMessage {
|
||||
ticketId: string;
|
||||
message: {
|
||||
id: string;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
globalName: string;
|
||||
avatar: string;
|
||||
bot: boolean;
|
||||
isDashboard?: boolean;
|
||||
};
|
||||
content: string;
|
||||
embeds?: { title?: string; description?: string; color?: string | null }[];
|
||||
attachments?: { id: string; name: string; url: string; contentType?: string }[];
|
||||
createdAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamTicketMessageDeleted {
|
||||
ticketId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface StreamTicketMessageUpdated {
|
||||
ticketId: string;
|
||||
messageId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface StreamTicketCreated {
|
||||
ticket_id: string;
|
||||
user_id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
department: string;
|
||||
priority: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface StreamTicketClosed {
|
||||
ticket_id: string;
|
||||
}
|
||||
|
||||
export interface StreamHandlers {
|
||||
onMetrics?: (metrics: StreamMetrics) => void;
|
||||
onLog?: (log: StreamLogEntry) => void;
|
||||
onTicketMessage?: (msg: StreamTicketMessage) => void;
|
||||
onTicketMessageDeleted?: (data: StreamTicketMessageDeleted) => void;
|
||||
onTicketMessageUpdated?: (data: StreamTicketMessageUpdated) => void;
|
||||
onTicketCreated?: (ticket: StreamTicketCreated) => void;
|
||||
onTicketClosed?: (ticket: StreamTicketClosed) => void;
|
||||
}
|
||||
|
||||
export function useRealtimeStream(handlers: StreamHandlers) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const handlersRef = useRef(handlers);
|
||||
handlersRef.current = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
let timerId: number | null = null;
|
||||
|
||||
const connect = () => {
|
||||
eventSource = new EventSource("/api/stream");
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
eventSource.addEventListener("connected", () => {
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
eventSource.addEventListener("metrics", (ev) => {
|
||||
try {
|
||||
const data: StreamMetrics = JSON.parse(ev.data);
|
||||
handlersRef.current.onMetrics?.(data);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("log", (ev) => {
|
||||
try {
|
||||
const data: StreamLogEntry = JSON.parse(ev.data);
|
||||
handlersRef.current.onLog?.(data);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("ticket_message", (ev) => {
|
||||
try {
|
||||
const data: StreamTicketMessage = JSON.parse(ev.data);
|
||||
handlersRef.current.onTicketMessage?.(data);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("ticket_message_deleted", (ev) => {
|
||||
try {
|
||||
const data: StreamTicketMessageDeleted = JSON.parse(ev.data);
|
||||
handlersRef.current.onTicketMessageDeleted?.(data);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("ticket_message_updated", (ev) => {
|
||||
try {
|
||||
const data: StreamTicketMessageUpdated = JSON.parse(ev.data);
|
||||
handlersRef.current.onTicketMessageUpdated?.(data);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("ticket_created", (ev) => {
|
||||
try {
|
||||
const data: StreamTicketCreated = JSON.parse(ev.data);
|
||||
handlersRef.current.onTicketCreated?.(data);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("ticket_closed", (ev) => {
|
||||
try {
|
||||
const data: StreamTicketClosed = JSON.parse(ev.data);
|
||||
handlersRef.current.onTicketClosed?.(data);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false);
|
||||
eventSource?.close();
|
||||
timerId = window.setTimeout(connect, 4000);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (timerId) window.clearTimeout(timerId);
|
||||
if (eventSource) eventSource.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 240 10% 4%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 7% 11%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 8% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 141 45% 49%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 6% 14%;
|
||||
--secondary-foreground: 0 0% 90%;
|
||||
--muted: 240 6% 14%;
|
||||
--muted-foreground: 240 5% 55%;
|
||||
--accent: 141 29% 18%;
|
||||
--accent-foreground: 141 52% 78%;
|
||||
--destructive: 0 72% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 240 6% 18%;
|
||||
--input: 240 6% 14%;
|
||||
--ring: 141 45% 49%;
|
||||
--radius: 0.75rem;
|
||||
--chart-1: 141 45% 49%;
|
||||
--chart-2: 199 89% 48%;
|
||||
--chart-3: 38 92% 50%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 0 72% 55%;
|
||||
--sidebar-background: 240 10% 5%;
|
||||
--sidebar-foreground: 240 5% 90%;
|
||||
--sidebar-primary: 141 45% 49%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 141 26% 14%;
|
||||
--sidebar-accent-foreground: 141 48% 82%;
|
||||
--sidebar-border: 240 6% 14%;
|
||||
--sidebar-ring: 141 45% 49%;
|
||||
--scrollbar-size: 6px;
|
||||
--scrollbar-thumb: 240 4% 32%;
|
||||
--scrollbar-thumb-hover: 240 4% 44%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background font-sans text-foreground antialiased;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--scrollbar-thumb)) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: var(--scrollbar-size);
|
||||
height: var(--scrollbar-size);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--scrollbar-thumb));
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--scrollbar-thumb-hover));
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:active {
|
||||
background-color: hsl(var(--scrollbar-thumb-hover));
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.dashboard-glow {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.promo-gradient {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.overview-card-glow {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.color-picker-swatch.react-colorful {
|
||||
width: 220px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.color-picker-swatch .react-colorful__saturation {
|
||||
border-radius: calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0 0;
|
||||
}
|
||||
|
||||
.color-picker-swatch .react-colorful__hue {
|
||||
height: 12px;
|
||||
border-radius: 0 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.color-picker-swatch .react-colorful__pointer {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.unicode-emoji-panel-host emoji-picker.unicode-emoji-picker-el {
|
||||
width: 100%;
|
||||
height: min(50vh, 380px);
|
||||
--background: hsl(var(--card));
|
||||
--border-color: hsl(var(--border));
|
||||
--button-active-background: hsl(var(--primary) / 0.25);
|
||||
--button-hover-background: hsl(var(--accent));
|
||||
--input-border-color: hsl(var(--border));
|
||||
--input-font-color: hsl(var(--foreground));
|
||||
--input-placeholder-color: hsl(var(--muted-foreground));
|
||||
--outline-color: hsl(var(--ring));
|
||||
--category-font-color: hsl(var(--muted-foreground));
|
||||
--indicator-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { isValidHex, normalizeHex } from "@/lib/normalize-hex";
|
||||
|
||||
export const ACCENT_STORAGE_KEY = "supportbot-dashboard-accent";
|
||||
export const DEFAULT_ACCENT_HEX = "#45b46c";
|
||||
|
||||
const CSS_VARS = [
|
||||
"--primary",
|
||||
"--ring",
|
||||
"--chart-1",
|
||||
"--sidebar-primary",
|
||||
"--sidebar-ring",
|
||||
"--accent",
|
||||
"--accent-foreground",
|
||||
"--sidebar-accent",
|
||||
"--sidebar-accent-foreground",
|
||||
] as const;
|
||||
|
||||
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||
const normalized = normalizeHex(hex, DEFAULT_ACCENT_HEX);
|
||||
const r = parseInt(normalized.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(normalized.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(normalized.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
};
|
||||
}
|
||||
|
||||
function hsl(h: number, s: number, l: number): string {
|
||||
return `${h} ${s}% ${l}%`;
|
||||
}
|
||||
|
||||
/** Apply accent colour to dashboard CSS variables (replaces default purple). */
|
||||
export function applyAccentTheme(hex: string): string {
|
||||
const normalized = normalizeHex(hex, DEFAULT_ACCENT_HEX);
|
||||
const { h, s, l } = hexToHsl(normalized);
|
||||
const root = document.documentElement;
|
||||
|
||||
const primaryS = Math.min(Math.max(s, 45), 85);
|
||||
const primaryL = Math.min(Math.max(l, 42), 62);
|
||||
|
||||
root.style.setProperty("--primary", hsl(h, primaryS, primaryL));
|
||||
root.style.setProperty("--ring", hsl(h, primaryS, primaryL));
|
||||
root.style.setProperty("--chart-1", hsl(h, primaryS, primaryL));
|
||||
root.style.setProperty("--sidebar-primary", hsl(h, primaryS, primaryL));
|
||||
root.style.setProperty("--sidebar-ring", hsl(h, primaryS, primaryL));
|
||||
root.style.setProperty("--primary-foreground", "0 0% 100%");
|
||||
root.style.setProperty("--sidebar-primary-foreground", "0 0% 100%");
|
||||
|
||||
root.style.setProperty("--accent", hsl(h, Math.round(primaryS * 0.64), 18));
|
||||
root.style.setProperty(
|
||||
"--accent-foreground",
|
||||
hsl(h, Math.min(Math.round(primaryS * 1.15), 90), 78),
|
||||
);
|
||||
root.style.setProperty("--sidebar-accent", hsl(h, Math.round(primaryS * 0.57), 14));
|
||||
root.style.setProperty(
|
||||
"--sidebar-accent-foreground",
|
||||
hsl(h, Math.min(Math.round(primaryS * 1.07), 88), 82),
|
||||
);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function clearAccentTheme(): void {
|
||||
for (const name of CSS_VARS) {
|
||||
document.documentElement.style.removeProperty(name);
|
||||
}
|
||||
document.documentElement.style.removeProperty("--primary-foreground");
|
||||
document.documentElement.style.removeProperty("--sidebar-primary-foreground");
|
||||
}
|
||||
|
||||
export function getStoredAccentHex(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(ACCENT_STORAGE_KEY);
|
||||
if (!raw || !isValidHex(raw)) return null;
|
||||
return normalizeHex(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAccentTheme(hex: string): string {
|
||||
const normalized = applyAccentTheme(hex);
|
||||
try {
|
||||
localStorage.setItem(ACCENT_STORAGE_KEY, normalized);
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resetAccentTheme(): void {
|
||||
try {
|
||||
localStorage.removeItem(ACCENT_STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
clearAccentTheme();
|
||||
}
|
||||
|
||||
export function initAccentTheme(): void {
|
||||
const stored = getStoredAccentHex();
|
||||
if (stored) applyAccentTheme(stored);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const ADDON_CONFIG_PREFIX = "addon:";
|
||||
|
||||
export function addonConfigFileParam(filename: string): string {
|
||||
return `${ADDON_CONFIG_PREFIX}${filename}`;
|
||||
}
|
||||
|
||||
export function parseConfigFileParam(
|
||||
file: string | null,
|
||||
): { type: "main" | "addon"; file: string } | null {
|
||||
if (!file) return null;
|
||||
if (file.startsWith(ADDON_CONFIG_PREFIX)) {
|
||||
return {
|
||||
type: "addon",
|
||||
file: file.slice(ADDON_CONFIG_PREFIX.length),
|
||||
};
|
||||
}
|
||||
return { type: "main", file };
|
||||
}
|
||||
|
||||
export function addonConfigLabel(filename: string): string {
|
||||
return filename
|
||||
.replace(/\.(yml|yaml|json)$/i, "")
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DashboardBranding } from "@/lib/branding-types";
|
||||
|
||||
const FAVICON_LINK_ID = "supportbot-dashboard-favicon";
|
||||
|
||||
export function applyDashboardBranding(branding: DashboardBranding) {
|
||||
if (branding.pageTitle) {
|
||||
document.title = branding.pageTitle;
|
||||
}
|
||||
|
||||
let link = document.getElementById(FAVICON_LINK_ID) as HTMLLinkElement | null;
|
||||
if (branding.faviconUrl) {
|
||||
const href = `${branding.faviconUrl}${branding.updatedAt ? `?v=${branding.updatedAt}` : ""}`;
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.id = FAVICON_LINK_ID;
|
||||
link.rel = "icon";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = href;
|
||||
} else if (link) {
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface DashboardBranding {
|
||||
title: string;
|
||||
pageTitle: string;
|
||||
hasFavicon: boolean;
|
||||
faviconUrl: string | null;
|
||||
updatedAt: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_BRANDING: DashboardBranding = {
|
||||
title: "SupportBot",
|
||||
pageTitle: "SupportBot Dashboard",
|
||||
hasFavicon: false,
|
||||
faviconUrl: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ConfigSchema } from "./types";
|
||||
import { messagesSchema } from "./messages";
|
||||
import { supportbotSchema } from "./supportbot";
|
||||
import { supportbotAiSchema } from "./supportbot-ai";
|
||||
import { ticketPanelSchema } from "./ticket-panel";
|
||||
|
||||
export type ConfigFileName =
|
||||
| "supportbot"
|
||||
| "ticket-panel"
|
||||
| "commands"
|
||||
| "messages"
|
||||
| "supportbot-ai";
|
||||
|
||||
export const configSchemas: Record<ConfigFileName, ConfigSchema | "commands"> = {
|
||||
supportbot: supportbotSchema,
|
||||
"ticket-panel": ticketPanelSchema,
|
||||
commands: "commands",
|
||||
messages: messagesSchema,
|
||||
"supportbot-ai": supportbotAiSchema,
|
||||
};
|
||||
|
||||
export function getSchema(file: string): ConfigSchema | "commands" | null {
|
||||
if (file in configSchemas) {
|
||||
return configSchemas[file as ConfigFileName];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ConfigSchema } from "./types";
|
||||
|
||||
export const messagesSchema: ConfigSchema = [
|
||||
{
|
||||
title: "Info command",
|
||||
fields: [
|
||||
{ path: "Info.Title", label: "Title", type: "text" },
|
||||
{ path: "Info.Description", label: "Description", type: "textarea" },
|
||||
{ path: "Info.Button", label: "Button label", type: "text" },
|
||||
{ path: "Info.URL", label: "Button URL", type: "text" },
|
||||
{ path: "Info.Colour", label: "Colour", type: "color" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Welcome message",
|
||||
fields: [
|
||||
{ path: "Welcome.Enabled", label: "Enabled", type: "boolean" },
|
||||
{
|
||||
path: "Welcome.LayoutStyle",
|
||||
label: "Layout style",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "modern", label: "Modern (components)" },
|
||||
{ value: "classic", label: "Classic embed" },
|
||||
],
|
||||
},
|
||||
{ path: "Welcome.Embed.Colour", label: "Embed colour", type: "color" },
|
||||
{ path: "Welcome.Embed.Title", label: "Embed title", type: "text" },
|
||||
{ path: "Welcome.Embed.Message", label: "Embed message", type: "textarea" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Leave message",
|
||||
fields: [
|
||||
{ path: "Leave.Enabled", label: "Enabled", type: "boolean" },
|
||||
{ path: "Leave.Embed.Colour", label: "Embed colour", type: "color" },
|
||||
{ path: "Leave.Embed.Title", label: "Embed title", type: "text" },
|
||||
{ path: "Leave.Embed.Message", label: "Embed message", type: "textarea" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ConfigSchema } from "./types";
|
||||
|
||||
const providers = [
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "groq", label: "Groq" },
|
||||
{ value: "claude", label: "Claude" },
|
||||
];
|
||||
|
||||
const messageTypes = [
|
||||
{ value: "text", label: "Plain text" },
|
||||
{ value: "embed", label: "Embed" },
|
||||
{ value: "components_v2", label: "Components v2" },
|
||||
];
|
||||
|
||||
const memoryScopes = [
|
||||
{ value: "channel", label: "Per channel" },
|
||||
{ value: "guild", label: "Per server" },
|
||||
];
|
||||
|
||||
export const supportbotAiSchema: ConfigSchema = [
|
||||
{
|
||||
title: "General",
|
||||
fields: [
|
||||
{ path: "Enabled", label: "AI enabled", type: "boolean" },
|
||||
{
|
||||
path: "General.Provider",
|
||||
label: "Provider",
|
||||
type: "select",
|
||||
options: providers,
|
||||
},
|
||||
{ path: "General.Model", label: "Model", type: "text" },
|
||||
{
|
||||
path: "General.Model_API_Key",
|
||||
label: "API key",
|
||||
type: "secret",
|
||||
placeholder: "MODEL_API_KEY",
|
||||
},
|
||||
{ path: "General.Tokens", label: "Max tokens", type: "number" },
|
||||
{
|
||||
path: "General.Temperature",
|
||||
label: "Temperature",
|
||||
type: "number",
|
||||
},
|
||||
{ path: "General.Name", label: "Display name", type: "text" },
|
||||
{
|
||||
path: "General.PastebinAPI_Key",
|
||||
label: "Pastebin API key",
|
||||
type: "secret",
|
||||
placeholder: "PASTEBIN_API_KEY",
|
||||
},
|
||||
{
|
||||
path: "General.PastebinAPI_URL",
|
||||
label: "Pastebin API URL",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Style & channel",
|
||||
fields: [
|
||||
{
|
||||
path: "Style.MessageType",
|
||||
label: "Message type",
|
||||
type: "select",
|
||||
options: messageTypes,
|
||||
},
|
||||
{
|
||||
path: "Features.SuggestTickets",
|
||||
label: "Suggest tickets",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Channels.AIChannel",
|
||||
label: "AI channel ID",
|
||||
type: "discordId",
|
||||
},
|
||||
{ path: "Embed.Color", label: "Embed colour", type: "color" },
|
||||
{ path: "Embed.Footer", label: "Embed footer", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Memory",
|
||||
fields: [
|
||||
{ path: "Memory.Enabled", label: "Memory enabled", type: "boolean" },
|
||||
{ path: "Memory.StoreFacts", label: "Store facts", type: "boolean" },
|
||||
{
|
||||
path: "Memory.StoreSummaries",
|
||||
label: "Store summaries",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Memory.MaxRecentMessages",
|
||||
label: "Max recent messages",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
path: "Memory.Scope",
|
||||
label: "Memory scope",
|
||||
type: "select",
|
||||
options: memoryScopes,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Custom instructions",
|
||||
fields: [
|
||||
{
|
||||
path: "CustomInstructions",
|
||||
label: "Instruction lines",
|
||||
type: "stringList",
|
||||
description: "One instruction per line sent to the AI.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Messages",
|
||||
fields: [
|
||||
{
|
||||
path: "Messages.ErrorResponse",
|
||||
label: "Error response",
|
||||
type: "text",
|
||||
},
|
||||
{ path: "Messages.AIError", label: "AI error", type: "text" },
|
||||
{ path: "Messages.AIDisabled", label: "AI disabled", type: "text" },
|
||||
{
|
||||
path: "Messages.MessageTooLong",
|
||||
label: "Message too long",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Messages.SuggestTicketText",
|
||||
label: "Suggest ticket text",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Messages.SuggestTicketReaction",
|
||||
label: "Suggest ticket emoji",
|
||||
type: "emoji",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,578 @@
|
||||
import type { ConfigSchema } from "./types";
|
||||
|
||||
const activityTypes = [
|
||||
{ value: "Playing", label: "Playing" },
|
||||
{ value: "Watching", label: "Watching" },
|
||||
{ value: "Listening", label: "Listening" },
|
||||
{ value: "Competing", label: "Competing" },
|
||||
{ value: "Streaming", label: "Streaming" },
|
||||
];
|
||||
|
||||
const suggestionModes = [
|
||||
{ value: "Channel", label: "Channel" },
|
||||
{ value: "Forum", label: "Forum" },
|
||||
];
|
||||
|
||||
const ticketTypes = [
|
||||
{ value: "channels", label: "Channels" },
|
||||
{ value: "threads", label: "Threads" },
|
||||
];
|
||||
|
||||
const ticketSubjects = [
|
||||
{ value: "description", label: "Channel description" },
|
||||
{ value: "embed", label: "Embed" },
|
||||
];
|
||||
|
||||
const buttonStyles = [
|
||||
{ value: "1", label: "Blurple" },
|
||||
{ value: "2", label: "Grey" },
|
||||
{ value: "3", label: "Green" },
|
||||
{ value: "4", label: "Red" },
|
||||
];
|
||||
|
||||
export const supportbotSchema: ConfigSchema = [
|
||||
{
|
||||
title: "General",
|
||||
fields: [
|
||||
{ path: "General.Name", label: "Bot name", type: "text" },
|
||||
{
|
||||
path: "General.Token",
|
||||
label: "Bot token",
|
||||
type: "secret",
|
||||
description: "Masked in the dashboard. Your real token is preserved on save.",
|
||||
},
|
||||
{
|
||||
path: "General.GuildId",
|
||||
label: "Server (guild) ID",
|
||||
type: "discordId",
|
||||
description:
|
||||
"Right-click your Discord server icon → Copy Server ID. The bot should only stay in this server.",
|
||||
placeholder: "Discord server snowflake ID",
|
||||
},
|
||||
{
|
||||
path: "General.Addons.Enabled",
|
||||
label: "Addons enabled",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Database",
|
||||
fields: [
|
||||
{
|
||||
path: "Database.Driver",
|
||||
label: "Database Engine",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "sqlite", label: "SQLite (Local File)" },
|
||||
{ value: "mysql", label: "MySQL / MariaDB" },
|
||||
],
|
||||
description: "Select SQLite for zero-config file storage, or MySQL for external host servers.",
|
||||
},
|
||||
{
|
||||
path: "Database.MySQL.Host",
|
||||
label: "MySQL Host",
|
||||
type: "text",
|
||||
placeholder: "127.0.0.1 or localhost",
|
||||
description: "Hostname or IP address of your MySQL database server.",
|
||||
},
|
||||
{
|
||||
path: "Database.MySQL.Port",
|
||||
label: "MySQL Port",
|
||||
type: "number",
|
||||
placeholder: "3306",
|
||||
},
|
||||
{
|
||||
path: "Database.MySQL.Database",
|
||||
label: "Database Name",
|
||||
type: "text",
|
||||
placeholder: "supportbot",
|
||||
},
|
||||
{
|
||||
path: "Database.MySQL.User",
|
||||
label: "Database User",
|
||||
type: "text",
|
||||
placeholder: "root",
|
||||
},
|
||||
{
|
||||
path: "Database.MySQL.Password",
|
||||
label: "Database Password",
|
||||
type: "secret",
|
||||
placeholder: "••••••••••••",
|
||||
},
|
||||
{
|
||||
path: "Database.MySQL.ConnectionLimit",
|
||||
label: "Connection Pool Limit",
|
||||
type: "number",
|
||||
placeholder: "10",
|
||||
description: "Maximum simultaneous connections in the MySQL pool (default 10).",
|
||||
},
|
||||
{
|
||||
path: "Database.SQLite.File",
|
||||
label: "SQLite Database File Path",
|
||||
type: "text",
|
||||
placeholder: "./Data/supportbot.db",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Activity",
|
||||
fields: [
|
||||
{ path: "Activity.Status", label: "Status text", type: "text" },
|
||||
{
|
||||
path: "Activity.Type",
|
||||
label: "Activity type",
|
||||
type: "select",
|
||||
options: activityTypes,
|
||||
},
|
||||
{
|
||||
path: "Activity.StreamingURL",
|
||||
label: "Streaming URL",
|
||||
type: "text",
|
||||
description: "Required when type is Streaming.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Embeds",
|
||||
fields: [
|
||||
{ path: "Embed.Colours.General", label: "General colour", type: "color" },
|
||||
{ path: "Embed.Colours.Success", label: "Success colour", type: "color" },
|
||||
{ path: "Embed.Colours.Error", label: "Error colour", type: "color" },
|
||||
{ path: "Embed.Colours.Warn", label: "Warn colour", type: "color" },
|
||||
{ path: "Embed.Footer", label: "Footer text", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
fields: [
|
||||
{
|
||||
path: "Roles.AutoRole.Enabled",
|
||||
label: "Auto role enabled",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Roles.AutoRole.Role",
|
||||
label: "Auto role",
|
||||
type: "discordRole",
|
||||
},
|
||||
{
|
||||
path: "Roles.StaffMember.Staff",
|
||||
label: "Staff role",
|
||||
type: "discordRole",
|
||||
},
|
||||
{
|
||||
path: "Roles.StaffMember.Admin",
|
||||
label: "Admin role",
|
||||
type: "discordRole",
|
||||
},
|
||||
{
|
||||
path: "Roles.StaffMember.Moderator",
|
||||
label: "Moderator role",
|
||||
type: "discordRole",
|
||||
},
|
||||
{
|
||||
path: "Roles.Mod.AllowSupportStaff",
|
||||
label: "Allow support staff /mod",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Welcome & leave",
|
||||
fields: [
|
||||
{ path: "Welcome.Enabled", label: "Welcome enabled", type: "boolean" },
|
||||
{
|
||||
path: "Welcome.Channel",
|
||||
label: "Welcome channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
{ path: "Leave.Enabled", label: "Leave enabled", type: "boolean" },
|
||||
{
|
||||
path: "Leave.Channel",
|
||||
label: "Leave channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Suggestions",
|
||||
fields: [
|
||||
{
|
||||
path: "Suggestions.Mode",
|
||||
label: "Mode",
|
||||
type: "select",
|
||||
options: suggestionModes,
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Channel",
|
||||
label: "Suggestions channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.ForumChannel",
|
||||
label: "Forum channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.UpvoteEmoji",
|
||||
label: "Upvote emoji",
|
||||
type: "emoji",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.DownvoteEmoji",
|
||||
label: "Downvote emoji",
|
||||
type: "emoji",
|
||||
},
|
||||
{ path: "Suggestions.OwnSuggestion", label: "Own suggestion votes", type: "boolean" },
|
||||
{ path: "Suggestions.ShowUsers", label: "Show users", type: "boolean" },
|
||||
{
|
||||
path: "Suggestions.UpvoteTitle",
|
||||
label: "Upvote button title",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.DownvoteTitle",
|
||||
label: "Downvote button title",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Threads.Enabled",
|
||||
label: "Suggestion threads",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Threads.Title",
|
||||
label: "Thread title",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Threads.Reason",
|
||||
label: "Thread reason",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Buttons.Upvote",
|
||||
label: "Upvote button style",
|
||||
type: "select",
|
||||
options: buttonStyles,
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Buttons.Downvote",
|
||||
label: "Downvote button style",
|
||||
type: "select",
|
||||
options: buttonStyles,
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Buttons.RemoveVote",
|
||||
label: "Remove vote button style",
|
||||
type: "select",
|
||||
options: buttonStyles,
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Buttons.RemoveVote_Title",
|
||||
label: "Remove vote title",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Tags.Created",
|
||||
label: "Tag: created",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Tags.Considering",
|
||||
label: "Tag: considering",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Tags.Solved",
|
||||
label: "Tag: solved",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Suggestions.Tags.Denied",
|
||||
label: "Tag: denied",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tickets",
|
||||
fields: [
|
||||
{
|
||||
path: "Ticket.TicketType",
|
||||
label: "Ticket type",
|
||||
type: "select",
|
||||
options: ticketTypes,
|
||||
},
|
||||
{
|
||||
path: "Ticket.UserManagement.DMOnAdd",
|
||||
label: "DM when added to ticket",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.UserManagement.DMOnRemove",
|
||||
label: "DM when removed from ticket",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.UserManagement.DMTranscript",
|
||||
label: "DM transcript on close",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.TicketHome",
|
||||
label: "Ticket home channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
{
|
||||
path: "Ticket.TicketChannelsCategory",
|
||||
label: "Ticket category",
|
||||
type: "discordCategory",
|
||||
},
|
||||
{
|
||||
path: "Ticket.TicketChannelsCategory2",
|
||||
label: "Overflow ticket category",
|
||||
type: "discordCategory",
|
||||
description: "Used when the first category is full.",
|
||||
},
|
||||
{
|
||||
path: "Ticket.TicketArchiveCategory",
|
||||
label: "Archive category",
|
||||
type: "discordCategory",
|
||||
},
|
||||
{ path: "Ticket.Channel", label: "Channel prefix", type: "text" },
|
||||
{
|
||||
path: "Ticket.TicketSubject",
|
||||
label: "Ticket subject display",
|
||||
type: "select",
|
||||
options: ticketSubjects,
|
||||
},
|
||||
{ path: "Ticket.Timeout", label: "Close timeout (minutes)", type: "number" },
|
||||
{
|
||||
path: "Ticket.TicketsPerUser",
|
||||
label: "Tickets per user",
|
||||
type: "number",
|
||||
},
|
||||
{ path: "Ticket.TicketReason", label: "Require reason", type: "boolean" },
|
||||
{
|
||||
path: "Ticket.Close.StaffOnly",
|
||||
label: "Close staff only",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.Invites.StaffOnly",
|
||||
label: "Ticket invites staff only",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.ClaimTickets.Enabled",
|
||||
label: "Claim tickets",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.ClaimTickets.Channel",
|
||||
label: "Claim log channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
{
|
||||
path: "Ticket.ClaimTickets.ClaimAcceptTime",
|
||||
label: "Claim accept time (ms)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
path: "Ticket.ClaimTickets.ButtonTitle",
|
||||
label: "Claim button title",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Ticket.ClaimTickets.ButtonEmoji",
|
||||
label: "Claim button emoji",
|
||||
type: "emoji",
|
||||
},
|
||||
{
|
||||
path: "Ticket.ClaimTickets.Button",
|
||||
label: "Claim button style",
|
||||
type: "select",
|
||||
options: buttonStyles,
|
||||
},
|
||||
{
|
||||
path: "Ticket.ReviewSystem.Enabled",
|
||||
label: "Review system",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.ReviewSystem.Channel",
|
||||
label: "Review channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
{
|
||||
path: "Ticket.ReviewSystem.UseModal",
|
||||
label: "Review uses modal",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.Log.TicketDataLog",
|
||||
label: "Ticket data log channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
{
|
||||
path: "Ticket.Log.TickeDataTitle",
|
||||
label: "Ticket data log title",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Ticket.Log.TicketBlacklistLog",
|
||||
label: "Blacklist log channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
{
|
||||
path: "Ticket.AIMode.Enabled",
|
||||
label: "AI mode in tickets",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.AIMode.ThreadName",
|
||||
label: "AI thread name",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Ticket.AIMode.WelcomeMessage",
|
||||
label: "AI welcome message",
|
||||
type: "textarea",
|
||||
},
|
||||
{
|
||||
path: "Ticket.Questions.Enabled",
|
||||
label: "Ticket questions",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.Questions.List",
|
||||
label: "Default questions",
|
||||
type: "stringList",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Ticket Departments",
|
||||
description: "Configure ticket department channels, emojis, staff roles, and custom department questions.",
|
||||
fields: [
|
||||
{
|
||||
path: "Ticket.DepartmentSystem.Enabled",
|
||||
label: "Departments enabled",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.DepartmentSystem.Placeholder",
|
||||
label: "Department placeholder",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Ticket.DepartmentSystem.DefaultDepartment",
|
||||
label: "Default department key",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Ticket Priorities",
|
||||
description: "Configure ticket priority levels and emojis.",
|
||||
fields: [
|
||||
{
|
||||
path: "Ticket.PrioritySystem.Enabled",
|
||||
label: "Priority system",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Ticket.PrioritySystem.DefaultPriority",
|
||||
label: "Default priority",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Ticket.PrioritySystem.AllowUsersToChooseOnOpen",
|
||||
label: "User picks priority on open",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Voice tickets",
|
||||
fields: [
|
||||
{ path: "VoiceTickets.Name", label: "VC name format", type: "text" },
|
||||
{
|
||||
path: "VoiceTickets.Category",
|
||||
label: "VC category",
|
||||
type: "discordCategory",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Logging",
|
||||
fields: [
|
||||
{
|
||||
path: "MessageDelete.Channel",
|
||||
label: "Message delete log channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
{
|
||||
path: "MessageDelete.Colour",
|
||||
label: "Message delete embed colour",
|
||||
type: "color",
|
||||
},
|
||||
{
|
||||
path: "MessageUpdate.Channel",
|
||||
label: "Message update log channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
{
|
||||
path: "MessageUpdate.Colour",
|
||||
label: "Message update embed colour",
|
||||
type: "color",
|
||||
},
|
||||
{
|
||||
path: "Translate.TranslateLog",
|
||||
label: "Translate log channel",
|
||||
type: "discordChannel",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Buttons & menus",
|
||||
description: "Ticket panel and control button labels and styles.",
|
||||
fields: [
|
||||
{ path: "Buttons.General.Delete", label: "Delete emoji", type: "emoji" },
|
||||
{
|
||||
path: "Buttons.General.DeleteStyle",
|
||||
label: "Delete button style",
|
||||
type: "select",
|
||||
options: buttonStyles,
|
||||
},
|
||||
{
|
||||
path: "Buttons.Voice.TicketDeleteText",
|
||||
label: "Voice delete label",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "Buttons.Tickets.ClaimEmoji",
|
||||
label: "Ticket claim emoji",
|
||||
type: "emoji",
|
||||
},
|
||||
{
|
||||
path: "Buttons.Tickets.ClaimStyle",
|
||||
label: "Ticket claim style",
|
||||
type: "select",
|
||||
options: buttonStyles,
|
||||
},
|
||||
{
|
||||
path: "SelectMenus.Tickets.PanelEmoji",
|
||||
label: "Panel menu emoji",
|
||||
type: "emoji",
|
||||
},
|
||||
{
|
||||
path: "SelectMenus.Tickets.CloseEmoji",
|
||||
label: "Close menu emoji",
|
||||
type: "emoji",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ConfigSchema } from "./types";
|
||||
|
||||
const buttonStyles = [
|
||||
{ value: "1", label: "Blurple" },
|
||||
{ value: "2", label: "Grey" },
|
||||
{ value: "3", label: "Green" },
|
||||
{ value: "4", label: "Red" },
|
||||
];
|
||||
|
||||
const layouts = [
|
||||
{ value: "1", label: "Style 1" },
|
||||
{ value: "2", label: "Style 2" },
|
||||
{ value: "3", label: "Style 3" },
|
||||
{ value: "4", label: "Style 4 (classic embed)" },
|
||||
];
|
||||
|
||||
export const ticketPanelSchema: ConfigSchema = [
|
||||
{
|
||||
title: "Panel",
|
||||
fields: [{ path: "Panel", label: "Panel enabled", type: "boolean" }],
|
||||
},
|
||||
{
|
||||
title: "Embed style",
|
||||
fields: [
|
||||
{ path: "Style.Title", label: "Title", type: "text" },
|
||||
{ path: "Style.Description", label: "Description", type: "textarea" },
|
||||
{ path: "Style.Image", label: "Image URL", type: "text" },
|
||||
{ path: "Style.Color", label: "Embed colour", type: "color" },
|
||||
{
|
||||
path: "Style.Layout",
|
||||
label: "Layout",
|
||||
type: "select",
|
||||
options: layouts,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Create button",
|
||||
fields: [
|
||||
{ path: "Button.Text", label: "Button text", type: "text" },
|
||||
{ path: "Button.Emoji", label: "Button emoji", type: "emoji" },
|
||||
{
|
||||
path: "Button.Color",
|
||||
label: "Button style",
|
||||
type: "select",
|
||||
options: buttonStyles,
|
||||
},
|
||||
{ path: "Button.CustomId", label: "Custom ID", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Department menu",
|
||||
fields: [
|
||||
{ path: "DepartmentMenu.CustomId", label: "Custom ID", type: "text" },
|
||||
{ path: "DepartmentMenu.Placeholder", label: "Placeholder", type: "text" },
|
||||
{
|
||||
path: "DepartmentMenu.DefaultDescription",
|
||||
label: "Default description",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
path: "DepartmentMenu.DefaultEmoji",
|
||||
label: "Default emoji",
|
||||
type: "emoji",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Panel settings",
|
||||
fields: [
|
||||
{ path: "Settings.Image", label: "Show image", type: "boolean" },
|
||||
{ path: "Settings.TopTitle", label: "Top title", type: "boolean" },
|
||||
{ path: "Settings.TopDivider", label: "Top divider", type: "boolean" },
|
||||
{
|
||||
path: "Settings.BottomTitle",
|
||||
label: "Bottom title",
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
path: "Settings.BottomDivider",
|
||||
label: "Bottom divider",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Messages",
|
||||
description: "Bot messages when using the ticket panel command.",
|
||||
fields: [
|
||||
{
|
||||
path: "Messages.NotConfigured",
|
||||
label: "Not configured",
|
||||
type: "textarea",
|
||||
},
|
||||
{
|
||||
path: "Messages.InvalidChannel",
|
||||
label: "Invalid channel",
|
||||
type: "textarea",
|
||||
},
|
||||
{ path: "Messages.PanelSent", label: "Panel sent", type: "text" },
|
||||
{
|
||||
path: "Messages.PanelSentWithDepartments",
|
||||
label: "Panel sent (departments)",
|
||||
type: "text",
|
||||
},
|
||||
{ path: "Messages.SendError", label: "Send error", type: "text" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
export type FieldType =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "boolean"
|
||||
| "number"
|
||||
| "color"
|
||||
| "emoji"
|
||||
| "select"
|
||||
| "discordId"
|
||||
| "discordChannel"
|
||||
| "discordRole"
|
||||
| "discordCategory"
|
||||
| "secret"
|
||||
| "stringList"
|
||||
| "googleFont";
|
||||
|
||||
export interface ConfigField {
|
||||
path: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
description?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface ConfigSection {
|
||||
title: string;
|
||||
description?: string;
|
||||
fields: ConfigField[];
|
||||
}
|
||||
|
||||
export type ConfigSchema = ConfigSection[];
|
||||
@@ -0,0 +1,57 @@
|
||||
export function getByPath(obj: Record<string, unknown>, path: string): unknown {
|
||||
const keys = path.split(".");
|
||||
let cur: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (cur == null || typeof cur !== "object") return undefined;
|
||||
cur = (cur as Record<string, unknown>)[key];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
export function setByPath(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
const keys = path.split(".");
|
||||
const clone = structuredClone(obj);
|
||||
let cur: Record<string, unknown> = clone;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (cur[key] == null || typeof cur[key] !== "object") {
|
||||
cur[key] = {};
|
||||
}
|
||||
cur = cur[key] as Record<string, unknown>;
|
||||
}
|
||||
cur[keys[keys.length - 1]] = value;
|
||||
return clone;
|
||||
}
|
||||
|
||||
export function flattenUpdates(
|
||||
configFile: string,
|
||||
obj: Record<string, unknown>,
|
||||
prefix = "",
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
!Array.isArray(val) &&
|
||||
Object.keys(val as object).length > 0 &&
|
||||
!isLeafObject(val as Record<string, unknown>)
|
||||
) {
|
||||
Object.assign(out, flattenUpdates(configFile, val as Record<string, unknown>, path));
|
||||
} else {
|
||||
out[`${configFile}:${path}`] = val;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isLeafObject(o: Record<string, unknown>): boolean {
|
||||
return Object.values(o).every(
|
||||
(v) => v === null || typeof v !== "object" || Array.isArray(v),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { api } from "@/api/client";
|
||||
import type {
|
||||
ClientAlertProvider,
|
||||
DashboardAlertPayload,
|
||||
DashboardAlertSeverity,
|
||||
} from "./types";
|
||||
|
||||
const SEVERITY_ORDER: Record<DashboardAlertSeverity, number> = {
|
||||
error: 0,
|
||||
warning: 1,
|
||||
info: 2,
|
||||
};
|
||||
|
||||
function sortAlerts(alerts: DashboardAlertPayload[]) {
|
||||
return [...alerts].sort((a, b) => {
|
||||
const sa = SEVERITY_ORDER[a.severity] ?? 9;
|
||||
const sb = SEVERITY_ORDER[b.severity] ?? 9;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeAlerts(alerts: DashboardAlertPayload[]) {
|
||||
const seen = new Set<string>();
|
||||
return alerts.filter((alert) => {
|
||||
if (seen.has(alert.id)) return false;
|
||||
seen.add(alert.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side alert registry. Server alerts come from GET /api/alerts;
|
||||
* register providers here for browser-only checks (feature flags, UI hints, etc.).
|
||||
*
|
||||
* Example:
|
||||
* dashboardAlertBuilder.register({
|
||||
* id: "local-hint",
|
||||
* evaluate: () => [{
|
||||
* id: "local:hint",
|
||||
* severity: "info",
|
||||
* category: "local",
|
||||
* title: "Tip",
|
||||
* message: "You can customise accent colour in Settings.",
|
||||
* }],
|
||||
* });
|
||||
*/
|
||||
class DashboardAlertBuilder {
|
||||
private clientProviders = new Map<string, ClientAlertProvider>();
|
||||
|
||||
register(provider: ClientAlertProvider) {
|
||||
this.clientProviders.set(provider.id, provider);
|
||||
return this;
|
||||
}
|
||||
|
||||
unregister(id: string) {
|
||||
this.clientProviders.delete(id);
|
||||
}
|
||||
|
||||
private async evaluateClientProviders() {
|
||||
const alerts: DashboardAlertPayload[] = [];
|
||||
for (const provider of this.clientProviders.values()) {
|
||||
const result = await provider.evaluate();
|
||||
if (Array.isArray(result) && result.length) {
|
||||
alerts.push(...result);
|
||||
}
|
||||
}
|
||||
return alerts;
|
||||
}
|
||||
|
||||
async loadAll(): Promise<DashboardAlertPayload[]> {
|
||||
const [serverRes, clientAlerts] = await Promise.all([
|
||||
api.getAlerts().catch(() => ({ data: [] as DashboardAlertPayload[] })),
|
||||
this.evaluateClientProviders(),
|
||||
]);
|
||||
|
||||
const serverAlerts = serverRes.data ?? [];
|
||||
return sortAlerts(dedupeAlerts([...serverAlerts, ...clientAlerts]));
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardAlertBuilder = new DashboardAlertBuilder();
|
||||
@@ -0,0 +1,12 @@
|
||||
export { dashboardAlertBuilder } from "./builder";
|
||||
export type {
|
||||
ClientAlertProvider,
|
||||
DashboardAlertAction,
|
||||
DashboardAlertDefinition,
|
||||
DashboardAlertGuildMeta,
|
||||
DashboardAlertModuleMeta,
|
||||
DashboardAlertPayload,
|
||||
DashboardAlertRenderContext,
|
||||
DashboardAlertSeverity,
|
||||
} from "./types";
|
||||
export { SEVERITY_STYLES } from "./types";
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { GuildHealthStatus, GuildSummary } from "@/api/client";
|
||||
|
||||
export type DashboardAlertSeverity = "error" | "warning" | "info";
|
||||
|
||||
export interface DashboardAlertAction {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface DashboardAlertGuildMeta {
|
||||
status?: GuildHealthStatus;
|
||||
configuredGuildId?: string | null;
|
||||
configuredGuild?: GuildSummary | null;
|
||||
extraGuilds?: GuildSummary[];
|
||||
}
|
||||
|
||||
export interface DashboardAlertModuleMeta {
|
||||
moduleId?: string;
|
||||
status?: "todo" | "progress" | "done";
|
||||
passed?: number;
|
||||
total?: number;
|
||||
progress?: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardAlertPayload {
|
||||
id: string;
|
||||
severity: DashboardAlertSeverity;
|
||||
category: string;
|
||||
title: string;
|
||||
message: string;
|
||||
action?: DashboardAlertAction;
|
||||
meta?: DashboardAlertGuildMeta & DashboardAlertModuleMeta & Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DashboardAlertRenderContext {
|
||||
canLeaveGuild: boolean;
|
||||
onLeaveGuild: (guildId: string) => Promise<void>;
|
||||
leavingGuildId: string | null;
|
||||
actionError: string;
|
||||
}
|
||||
|
||||
export interface ClientAlertProvider {
|
||||
id: string;
|
||||
evaluate: () => DashboardAlertPayload[] | Promise<DashboardAlertPayload[]>;
|
||||
}
|
||||
|
||||
export interface DashboardAlertDefinition extends DashboardAlertPayload {
|
||||
/** Optional custom body rendered below the message */
|
||||
body?: ReactNode;
|
||||
}
|
||||
|
||||
export const SEVERITY_STYLES: Record<
|
||||
DashboardAlertSeverity,
|
||||
{ border: string; bg: string; icon: string; title: string }
|
||||
> = {
|
||||
error: {
|
||||
border: "border-destructive/40",
|
||||
bg: "bg-destructive/10",
|
||||
icon: "text-destructive",
|
||||
title: "text-destructive",
|
||||
},
|
||||
warning: {
|
||||
border: "border-amber-500/40",
|
||||
bg: "bg-amber-500/10",
|
||||
icon: "text-amber-500",
|
||||
title: "text-amber-200",
|
||||
},
|
||||
info: {
|
||||
border: "border-sky-500/40",
|
||||
bg: "bg-sky-500/10",
|
||||
icon: "text-sky-400",
|
||||
title: "text-sky-100",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Discord custom emoji mention: <:name:id> or <a:name:id> */
|
||||
const CUSTOM_EMOJI_RE = /^<(a?):([^:>]+):(\d{17,20})>$/;
|
||||
|
||||
export interface GuildEmojiOption {
|
||||
id: string;
|
||||
name: string;
|
||||
animated: boolean;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedEmoji {
|
||||
kind: "unicode" | "custom" | "empty";
|
||||
/** Value stored in YAML */
|
||||
value: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
export function formatCustomEmoji(
|
||||
name: string,
|
||||
id: string,
|
||||
animated = false,
|
||||
): string {
|
||||
const prefix = animated ? "<a" : "<";
|
||||
return `${prefix}:${name}:${id}>`;
|
||||
}
|
||||
|
||||
export function parseEmojiValue(raw: unknown): ParsedEmoji {
|
||||
const value = String(raw ?? "").trim();
|
||||
if (!value) {
|
||||
return { kind: "empty", value: "" };
|
||||
}
|
||||
|
||||
const match = value.match(CUSTOM_EMOJI_RE);
|
||||
if (match) {
|
||||
return {
|
||||
kind: "custom",
|
||||
value,
|
||||
animated: match[1] === "a",
|
||||
name: match[2],
|
||||
id: match[3],
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "unicode", value };
|
||||
}
|
||||
|
||||
export function emojiCdnUrl(id: string, animated = false): string {
|
||||
return `https://cdn.discordapp.com/emojis/${id}.${animated ? "gif" : "png"}?size=64`;
|
||||
}
|
||||
|
||||
export function resolveEmojiPreview(
|
||||
value: unknown,
|
||||
guildEmojis: GuildEmojiOption[] = [],
|
||||
): ParsedEmoji {
|
||||
const parsed = parseEmojiValue(value);
|
||||
if (parsed.kind === "custom" && parsed.id && !parsed.name) {
|
||||
const found = guildEmojis.find((e) => e.id === parsed.id);
|
||||
if (found) {
|
||||
return {
|
||||
kind: "custom",
|
||||
value: formatCustomEmoji(found.name, found.id, found.animated),
|
||||
name: found.name,
|
||||
id: found.id,
|
||||
animated: found.animated,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (parsed.kind === "custom" && parsed.id) {
|
||||
const found = guildEmojis.find((e) => e.id === parsed.id);
|
||||
if (found && parsed.value !== formatCustomEmoji(found.name, found.id, found.animated)) {
|
||||
return {
|
||||
kind: "custom",
|
||||
value: formatCustomEmoji(found.name, found.id, found.animated),
|
||||
name: found.name,
|
||||
id: found.id,
|
||||
animated: found.animated,
|
||||
};
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function isEmojiFieldKey(key: string): boolean {
|
||||
return /emoji/i.test(key);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function formatLogTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ConfigField, ConfigSchema, FieldType } from "@/lib/config-schemas";
|
||||
|
||||
function labelFromKey(key: string): string {
|
||||
return key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function inferFieldType(key: string, value: unknown): FieldType {
|
||||
if (typeof value === "boolean") return "boolean";
|
||||
if (typeof value === "number") return "number";
|
||||
if (Array.isArray(value)) return "stringList";
|
||||
|
||||
if (typeof value === "string") {
|
||||
const k = key.toLowerCase();
|
||||
if (/prefix/.test(k)) return "text";
|
||||
if (/emoji/.test(k)) return "emoji";
|
||||
if (/guild.?id|guild_id/.test(k)) return "discordId";
|
||||
if (/category/.test(k)) return "discordCategory";
|
||||
if (/channel|forum/.test(k)) return "discordChannel";
|
||||
if (/role|staff|admin|moderator/.test(k)) return "discordRole";
|
||||
if (value.length > 100) return "textarea";
|
||||
return "text";
|
||||
}
|
||||
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function collectConfigFields(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = "",
|
||||
): ConfigField[] {
|
||||
const fields: ConfigField[] = [];
|
||||
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
const hasObjects =
|
||||
val.length > 0 &&
|
||||
typeof val[0] === "object" &&
|
||||
val[0] !== null &&
|
||||
!Array.isArray(val[0]);
|
||||
if (hasObjects) continue;
|
||||
fields.push({
|
||||
path,
|
||||
label: labelFromKey(key),
|
||||
type: "stringList",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
val &&
|
||||
typeof val === "object" &&
|
||||
Object.keys(val as object).length > 0
|
||||
) {
|
||||
fields.push(...collectConfigFields(val as Record<string, unknown>, path));
|
||||
continue;
|
||||
}
|
||||
|
||||
fields.push({
|
||||
path,
|
||||
label: labelFromKey(key),
|
||||
type: inferFieldType(key, val),
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function schemaFromObject(data: Record<string, unknown>): ConfigSchema {
|
||||
const fields = collectConfigFields(data);
|
||||
if (fields.length === 0) {
|
||||
return [
|
||||
{
|
||||
title: "Configuration",
|
||||
fields: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
title: "Settings",
|
||||
description: "Values from this addon’s config file.",
|
||||
fields,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Curated Google Fonts (keep in sync with Structures/googleFonts.js). */
|
||||
|
||||
export const SYSTEM_FONT_ID = "system";
|
||||
|
||||
export const GOOGLE_FONTS = [
|
||||
{ id: "Inter", label: "Inter" },
|
||||
{ id: "Roboto", label: "Roboto" },
|
||||
{ id: "Open Sans", label: "Open Sans" },
|
||||
{ id: "Lato", label: "Lato" },
|
||||
{ id: "Montserrat", label: "Montserrat" },
|
||||
{ id: "Poppins", label: "Poppins" },
|
||||
{ id: "Nunito", label: "Nunito" },
|
||||
{ id: "Raleway", label: "Raleway" },
|
||||
{ id: "Work Sans", label: "Work Sans" },
|
||||
{ id: "Source Sans 3", label: "Source Sans 3" },
|
||||
{ id: "DM Sans", label: "DM Sans" },
|
||||
{ id: "Manrope", label: "Manrope" },
|
||||
{ id: "Outfit", label: "Outfit" },
|
||||
{ id: "Rubik", label: "Rubik" },
|
||||
{ id: "Ubuntu", label: "Ubuntu" },
|
||||
{ id: "Fira Sans", label: "Fira Sans" },
|
||||
{ id: "PT Sans", label: "PT Sans" },
|
||||
{ id: "Noto Sans", label: "Noto Sans" },
|
||||
{ id: "Oswald", label: "Oswald" },
|
||||
{ id: "Merriweather", label: "Merriweather" },
|
||||
{ id: "Playfair Display", label: "Playfair Display" },
|
||||
{ id: "Libre Baskerville", label: "Libre Baskerville" },
|
||||
{ id: "JetBrains Mono", label: "JetBrains Mono" },
|
||||
{ id: "Fira Code", label: "Fira Code" },
|
||||
] as const;
|
||||
|
||||
export const GOOGLE_FONT_IDS = new Set<string>(
|
||||
GOOGLE_FONTS.map((f) => f.id),
|
||||
);
|
||||
|
||||
export function googleFontsCssUrl(familyIds: string[]) {
|
||||
const families = familyIds
|
||||
.map((id) => {
|
||||
const encoded = encodeURIComponent(id).replace(/%20/g, "+");
|
||||
return `family=${encoded}:wght@400;600;700;800`;
|
||||
})
|
||||
.join("&");
|
||||
return `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
}
|
||||
|
||||
/** Load all curated fonts in the dashboard (dropdown + preview labels). */
|
||||
export function injectGoogleFontsStylesheet() {
|
||||
const id = "supportbot-google-fonts";
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = googleFontsCssUrl(GOOGLE_FONTS.map((f) => f.id));
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
export function normalizeGoogleFontValue(fontFamily: unknown): string {
|
||||
if (!fontFamily || typeof fontFamily !== "string") return "Inter";
|
||||
const trimmed = fontFamily.trim();
|
||||
if (trimmed === SYSTEM_FONT_ID) return SYSTEM_FONT_ID;
|
||||
if (!trimmed.includes(",")) {
|
||||
return GOOGLE_FONT_IDS.has(trimmed) ? trimmed : "Inter";
|
||||
}
|
||||
const match = trimmed.match(/^["']?([^"',]+)["']?/);
|
||||
const first = match?.[1]?.trim();
|
||||
if (first && GOOGLE_FONT_IDS.has(first)) {
|
||||
return first;
|
||||
}
|
||||
return "Inter";
|
||||
}
|
||||
|
||||
export function fontFamilyCss(fontId: string): string {
|
||||
if (fontId === SYSTEM_FONT_ID) {
|
||||
return 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
}
|
||||
return `"${fontId}", ui-sans-serif, system-ui, sans-serif`;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ConfigField, ConfigSchema } from "@/lib/config-schemas";
|
||||
import { collectConfigFields } from "@/lib/generic-config-schema";
|
||||
|
||||
function sectionTitleFromPath(path: string): string {
|
||||
if (path.startsWith("Ticket.DepartmentSystem.")) return "Ticket Departments";
|
||||
if (path.startsWith("Ticket.PrioritySystem.")) return "Ticket Priorities";
|
||||
if (path.startsWith("Ticket.")) return "Tickets";
|
||||
const top = path.split(".")[0] ?? "Other";
|
||||
return top
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Adds any leaf paths from `data` that are not already in `schema`. */
|
||||
export function mergeSchemaWithData(
|
||||
schema: ConfigSchema,
|
||||
data: Record<string, unknown>,
|
||||
): ConfigSchema {
|
||||
const known = new Set(schema.flatMap((s) => s.fields.map((f) => f.path)));
|
||||
const discovered = collectConfigFields(data).filter((f) => !known.has(f.path));
|
||||
|
||||
if (discovered.length === 0) return schema;
|
||||
|
||||
const result: ConfigSchema = schema.map((sec) => ({
|
||||
...sec,
|
||||
fields: [...sec.fields],
|
||||
}));
|
||||
|
||||
const bySection = new Map<string, ConfigField[]>();
|
||||
for (const field of discovered) {
|
||||
const title = sectionTitleFromPath(field.path);
|
||||
const list = bySection.get(title) ?? [];
|
||||
list.push(field);
|
||||
bySection.set(title, list);
|
||||
}
|
||||
|
||||
for (const [title, fields] of bySection.entries()) {
|
||||
const existing = result.find(
|
||||
(s) => s.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.fields.push(...fields);
|
||||
} else {
|
||||
result.push({
|
||||
title,
|
||||
description: "These options exist in your config file but are not in the main editor sections.",
|
||||
fields: fields.sort((a, b) => a.path.localeCompare(b.path)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ModuleStatus } from "@/components/overview/module-strip-card";
|
||||
|
||||
export interface ModuleStatusInfo {
|
||||
status: ModuleStatus;
|
||||
subtitle: string;
|
||||
passed?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export const STATUS_BADGE: Record<ModuleStatus, string> = {
|
||||
todo: "Needs setup",
|
||||
progress: "In progress",
|
||||
done: "Active",
|
||||
};
|
||||
|
||||
export function getPluginModuleMeta(
|
||||
pluginId: string,
|
||||
modules?: Record<string, ModuleStatusInfo>,
|
||||
fallbackSubtitle = "Configure module",
|
||||
): ModuleStatusInfo {
|
||||
const info = modules?.[pluginId];
|
||||
if (info) return info;
|
||||
return {
|
||||
status: "todo",
|
||||
subtitle: fallbackSubtitle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Normalize user/config colour strings to #rrggbb (lowercase). */
|
||||
export function normalizeHex(value: string, fallback = "#000000"): string {
|
||||
let s = String(value ?? "").trim();
|
||||
if (!s) return fallback;
|
||||
if (!s.startsWith("#")) s = `#${s}`;
|
||||
const hex = s.slice(1);
|
||||
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
|
||||
return `#${hex
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("")
|
||||
.toLowerCase()}`;
|
||||
}
|
||||
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
return `#${hex.toLowerCase()}`;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function isValidHex(value: string): boolean {
|
||||
const s = String(value ?? "").trim();
|
||||
return /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(s) || /^[0-9a-fA-F]{6}$/.test(s);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { toast } from "sonner";
|
||||
import type { ApiResponse } from "@/api/client";
|
||||
|
||||
export async function notifyConfigSave<T>(
|
||||
saveFn: () => Promise<ApiResponse<T>>,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const saveToast = toast.loading("Saving changes…");
|
||||
|
||||
try {
|
||||
const res = await saveFn();
|
||||
toast.success("Changes saved", { id: saveToast });
|
||||
|
||||
if (res.botRestart) {
|
||||
const restartToast = toast.loading("Restarting bot…");
|
||||
if (res.botRestart.success) {
|
||||
toast.success("Bot started", { id: restartToast });
|
||||
} else {
|
||||
toast.error(res.botRestart.error ?? "Could not start bot", {
|
||||
id: restartToast,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Save failed", {
|
||||
id: saveToast,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
export const CONFIG_FILES = [
|
||||
"supportbot",
|
||||
"ticket-panel",
|
||||
"commands",
|
||||
"messages",
|
||||
"supportbot-ai",
|
||||
] as const;
|
||||
|
||||
export type ConfigFile = (typeof CONFIG_FILES)[number];
|
||||
|
||||
export type DashboardRole =
|
||||
| "owner"
|
||||
| "admin"
|
||||
| "moderator"
|
||||
| "editor"
|
||||
| "viewer"
|
||||
| "custom";
|
||||
|
||||
export interface ConfigFilePermission {
|
||||
view: boolean;
|
||||
edit: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardPermissions {
|
||||
overview: boolean;
|
||||
tickets: boolean;
|
||||
logs: boolean;
|
||||
transcripts: boolean;
|
||||
rawYaml?: boolean;
|
||||
settings: { view: boolean; update: boolean };
|
||||
configs: Record<ConfigFile, ConfigFilePermission>;
|
||||
users: { view: boolean; manage: boolean };
|
||||
}
|
||||
|
||||
export interface DashboardUser {
|
||||
id: string;
|
||||
username: string;
|
||||
globalName: string | null;
|
||||
avatar: string;
|
||||
role: string;
|
||||
groupId?: string;
|
||||
isOwner: boolean;
|
||||
permissions: DashboardPermissions;
|
||||
}
|
||||
|
||||
export interface StoredDashboardUser extends DashboardUser {
|
||||
enabled: boolean;
|
||||
yamlOwner: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DashboardGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
discordRoleId: string | null;
|
||||
isSystem: boolean;
|
||||
permissions: DashboardPermissions;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export const ROLE_LABELS: Record<DashboardRole, string> = {
|
||||
owner: "Owner",
|
||||
admin: "Admin",
|
||||
moderator: "Moderator",
|
||||
editor: "Editor",
|
||||
viewer: "Viewer",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
export const ROLE_HINTS: Record<DashboardRole, string> = {
|
||||
owner: "Full access to everything",
|
||||
admin: "Full access including user management",
|
||||
moderator: "View dashboard, logs, transcripts, and configs",
|
||||
editor: "Edit configuration files",
|
||||
viewer: "View-only access",
|
||||
custom: "Permissions you configure below",
|
||||
};
|
||||
|
||||
export const CONFIG_FILE_LABELS: Record<ConfigFile, string> = {
|
||||
supportbot: "Bot config",
|
||||
"ticket-panel": "Ticket panel",
|
||||
commands: "Commands",
|
||||
messages: "Messages",
|
||||
"supportbot-ai": "AI assistant",
|
||||
};
|
||||
|
||||
export function hasPermission(
|
||||
permissions: DashboardPermissions | null | undefined,
|
||||
key: string,
|
||||
): boolean {
|
||||
if (!permissions) return false;
|
||||
|
||||
if (key === "overview") return permissions.overview;
|
||||
if (key === "tickets") return permissions.tickets ?? permissions.overview;
|
||||
if (key === "logs") return permissions.logs;
|
||||
if (key === "transcripts") return permissions.transcripts;
|
||||
if (key === "rawYaml" || key === "configs.raw_yaml" || key === "configs.rawYaml") {
|
||||
return Boolean(permissions.rawYaml ?? (permissions.configs as unknown as Record<string, boolean>)?.rawYaml ?? false);
|
||||
}
|
||||
if (key === "settings.view") return permissions.settings.view;
|
||||
if (key === "settings.update") return permissions.settings.update;
|
||||
if (key === "users.view") return permissions.users.view;
|
||||
if (key === "users.manage") return permissions.users.manage;
|
||||
|
||||
const viewMatch = key.match(/^configs\.([a-z0-9-]+)\.view$/);
|
||||
if (viewMatch) {
|
||||
const file = viewMatch[1] as ConfigFile;
|
||||
return Boolean(permissions.configs[file]?.view);
|
||||
}
|
||||
|
||||
const editMatch = key.match(/^configs\.([a-z0-9-]+)\.edit$/);
|
||||
if (editMatch) {
|
||||
const file = editMatch[1] as ConfigFile;
|
||||
return Boolean(permissions.configs[file]?.edit);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canViewConfig(
|
||||
permissions: DashboardPermissions | null | undefined,
|
||||
file: string,
|
||||
): boolean {
|
||||
return hasPermission(permissions, `configs.${file}.view`);
|
||||
}
|
||||
|
||||
export function canEditConfig(
|
||||
permissions: DashboardPermissions | null | undefined,
|
||||
file: string,
|
||||
): boolean {
|
||||
return hasPermission(permissions, `configs.${file}.edit`);
|
||||
}
|
||||
|
||||
export function canViewAnyConfig(
|
||||
permissions: DashboardPermissions | null | undefined,
|
||||
): boolean {
|
||||
return CONFIG_FILES.some((file) => canViewConfig(permissions, file));
|
||||
}
|
||||
|
||||
function configMap(view: boolean, edit: boolean): Record<ConfigFile, ConfigFilePermission> {
|
||||
return CONFIG_FILES.reduce(
|
||||
(acc, file) => {
|
||||
acc[file] = { view, edit };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<ConfigFile, ConfigFilePermission>,
|
||||
);
|
||||
}
|
||||
|
||||
export function fullPermissions(
|
||||
rawYaml: boolean = true,
|
||||
userManage: boolean = true,
|
||||
): DashboardPermissions {
|
||||
return {
|
||||
overview: true,
|
||||
tickets: true,
|
||||
logs: true,
|
||||
transcripts: true,
|
||||
rawYaml,
|
||||
settings: { view: true, update: true },
|
||||
configs: configMap(true, true),
|
||||
users: { view: true, manage: userManage },
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultPermissionsForRole(
|
||||
role: DashboardRole,
|
||||
custom?: DashboardPermissions,
|
||||
): DashboardPermissions {
|
||||
switch (role) {
|
||||
case "owner":
|
||||
return fullPermissions(true, true);
|
||||
case "admin":
|
||||
return fullPermissions(false, false);
|
||||
case "moderator":
|
||||
return {
|
||||
overview: true,
|
||||
tickets: true,
|
||||
logs: true,
|
||||
transcripts: true,
|
||||
rawYaml: false,
|
||||
settings: { view: true, update: false },
|
||||
configs: configMap(true, false),
|
||||
users: { view: false, manage: false },
|
||||
};
|
||||
case "editor":
|
||||
return {
|
||||
overview: true,
|
||||
tickets: true,
|
||||
logs: true,
|
||||
transcripts: false,
|
||||
rawYaml: false,
|
||||
settings: { view: true, update: false },
|
||||
configs: configMap(true, true),
|
||||
users: { view: false, manage: false },
|
||||
};
|
||||
case "viewer":
|
||||
return {
|
||||
overview: true,
|
||||
tickets: true,
|
||||
logs: true,
|
||||
transcripts: true,
|
||||
rawYaml: false,
|
||||
settings: { view: true, update: false },
|
||||
configs: configMap(true, false),
|
||||
users: { view: false, manage: false },
|
||||
};
|
||||
case "custom":
|
||||
return custom ?? defaultPermissionsForRole("viewer");
|
||||
default:
|
||||
return defaultPermissionsForRole("viewer");
|
||||
}
|
||||
}
|
||||
|
||||
export function displayDiscordName(
|
||||
user: Pick<DashboardUser, "username" | "globalName">,
|
||||
): string {
|
||||
return user.globalName || user.username;
|
||||
}
|
||||
|
||||
export function firstAllowedPath(
|
||||
permissions: DashboardPermissions | null | undefined,
|
||||
): string {
|
||||
if (hasPermission(permissions, "overview")) return "/";
|
||||
if (hasPermission(permissions, "logs")) return "/logs";
|
||||
if (canViewAnyConfig(permissions)) return "/configs?file=supportbot";
|
||||
if (hasPermission(permissions, "transcripts")) return "/transcripts";
|
||||
if (hasPermission(permissions, "settings.view")) return "/settings";
|
||||
if (hasPermission(permissions, "users.view")) return "/users";
|
||||
return "/login";
|
||||
}
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user