Compare commits
31 Commits
feat/log-d
...
chore/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39bca3b39f | ||
|
|
b4b07f3120 | ||
|
|
e39faa1172 | ||
|
|
b46979f539 | ||
|
|
f4237fffe6 | ||
|
|
1d9bb96367 | ||
|
|
bcb449e394 | ||
|
|
ae4bc77407 | ||
|
|
6056167063 | ||
|
|
adf91c7dfd | ||
|
|
bba9a94e45 | ||
|
|
e36eb64c59 | ||
|
|
201fe05493 | ||
|
|
25490b2223 | ||
|
|
b3108942db | ||
|
|
2ba759966e | ||
|
|
7045cd8e25 | ||
|
|
603cc46b7d | ||
|
|
e9d338384a | ||
|
|
8a2a9ecef5 | ||
|
|
3a44957662 | ||
|
|
20e6c99e32 | ||
|
|
c0aae5449f | ||
|
|
596f346181 | ||
|
|
7b59e2ac07 | ||
|
|
2a9b1fd2b5 | ||
|
|
a2d4314e14 | ||
|
|
f94e4d6da2 | ||
|
|
97f4defc0e | ||
|
|
dd29dc89fd | ||
|
|
486cdf8313 |
@@ -103,6 +103,9 @@ checksums:
|
||||
tMYIu7/message: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||
tMYIu7/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
tMYIu7/translation: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||
UGCIzB/message: f7b6699c16726afc48f1e1d1339b2e7e
|
||||
UGCIzB/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
UGCIzB/translation: f7b6699c16726afc48f1e1d1339b2e7e
|
||||
B3xxao/message: 9d80becc4fe65a74e0004e4fd290735d
|
||||
B3xxao/origin/0/0: 6812b79a8fdc3527586b523d4c137340
|
||||
B3xxao/translation: 9d80becc4fe65a74e0004e4fd290735d
|
||||
@@ -235,6 +238,9 @@ checksums:
|
||||
ZXSPJt/message: 1b5749b0cca6a62d75a577bee6804074
|
||||
ZXSPJt/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
ZXSPJt/translation: 1b5749b0cca6a62d75a577bee6804074
|
||||
YkfDoz/message: 5d4f1c56f4e9e579ec9967ca68cc64ac
|
||||
YkfDoz/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
YkfDoz/translation: 5d4f1c56f4e9e579ec9967ca68cc64ac
|
||||
3bqt9U/message: 2366ed295eb2c03c425559c24cb31606
|
||||
3bqt9U/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
3bqt9U/translation: 2366ed295eb2c03c425559c24cb31606
|
||||
@@ -316,6 +322,9 @@ checksums:
|
||||
1rWxEw/message: 122c108794f321c10e05a08f09b11457
|
||||
1rWxEw/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
1rWxEw/translation: 122c108794f321c10e05a08f09b11457
|
||||
iH8pgl/message: f541015a827e37cb3b1234e56bc2aa3c
|
||||
iH8pgl/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
iH8pgl/translation: f541015a827e37cb3b1234e56bc2aa3c
|
||||
KNKCTb/message: 35919fd5b10ad212839a75e72829fe4e
|
||||
KNKCTb/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
KNKCTb/translation: 35919fd5b10ad212839a75e72829fe4e
|
||||
@@ -334,6 +343,9 @@ checksums:
|
||||
UoSI%2Bi/message: 47f1051a134e111930be7f94179df4c8
|
||||
UoSI%2Bi/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
UoSI%2Bi/translation: 47f1051a134e111930be7f94179df4c8
|
||||
zt%2F6cS/message: aadb69a7f7421be6bb022fbde08a6d77
|
||||
zt%2F6cS/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
zt%2F6cS/translation: aadb69a7f7421be6bb022fbde08a6d77
|
||||
qaS%2B1%2F/message: 4e25347caf65e1b63bec90e101b86ddd
|
||||
qaS%2B1%2F/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
qaS%2B1%2F/translation: 4e25347caf65e1b63bec90e101b86ddd
|
||||
@@ -360,8 +372,7 @@ checksums:
|
||||
QD8opX/origin/2/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
QD8opX/translation: 101ff39aab674c033c15ab978d0d8bac
|
||||
YhvWXb/message: 336a8c70a91adc49c15266167ee84cc0
|
||||
YhvWXb/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
YhvWXb/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
YhvWXb/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
YhvWXb/translation: 336a8c70a91adc49c15266167ee84cc0
|
||||
i0ZMQl/message: f739058266a2cb6a3d7cca14de09c69a
|
||||
i0ZMQl/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
@@ -516,23 +527,28 @@ checksums:
|
||||
dEgA5A/message: 2e2a849c2223911717de8caa2c71bade
|
||||
dEgA5A/origin/0/0: cf5db0532c4f1cf49cebe78926c03385
|
||||
dEgA5A/origin/1/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
dEgA5A/origin/2/0: 522726bb62051656dc0d165185d6b451
|
||||
dEgA5A/origin/3/0: b2059fa4b8b99cc10440da26475b234c
|
||||
dEgA5A/origin/4/0: 2a382c1cdf010f01a8e95c3e05f6df53
|
||||
dEgA5A/origin/5/0: d55f532bb5ae247e3a018978be0da463
|
||||
dEgA5A/origin/6/0: 4b4f55c033add8db69178563832227c6
|
||||
dEgA5A/origin/7/0: 68450df287fb46ea629e818732c1291a
|
||||
dEgA5A/origin/8/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
dEgA5A/origin/9/0: 0627e0040ca4939c9a57349e98c51797
|
||||
dEgA5A/origin/10/0: 15792234f408027822ee71ccddde6f9b
|
||||
dEgA5A/origin/11/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
dEgA5A/origin/12/0: a1fde3fcce608870830f168f027727ff
|
||||
dEgA5A/origin/13/0: 83911e3eacbad4583e2b1647a784d154
|
||||
dEgA5A/origin/14/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
dEgA5A/origin/2/0: 97bd206a05414e2a571020d64e166093
|
||||
dEgA5A/origin/3/0: 522726bb62051656dc0d165185d6b451
|
||||
dEgA5A/origin/4/0: b2059fa4b8b99cc10440da26475b234c
|
||||
dEgA5A/origin/5/0: 2a382c1cdf010f01a8e95c3e05f6df53
|
||||
dEgA5A/origin/6/0: d55f532bb5ae247e3a018978be0da463
|
||||
dEgA5A/origin/7/0: 4b4f55c033add8db69178563832227c6
|
||||
dEgA5A/origin/8/0: 68450df287fb46ea629e818732c1291a
|
||||
dEgA5A/origin/9/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
dEgA5A/origin/10/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
dEgA5A/origin/11/0: 0627e0040ca4939c9a57349e98c51797
|
||||
dEgA5A/origin/12/0: 15792234f408027822ee71ccddde6f9b
|
||||
dEgA5A/origin/13/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
dEgA5A/origin/14/0: a1fde3fcce608870830f168f027727ff
|
||||
dEgA5A/origin/15/0: 83911e3eacbad4583e2b1647a784d154
|
||||
dEgA5A/origin/16/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
dEgA5A/translation: 2e2a849c2223911717de8caa2c71bade
|
||||
kryGs%2B/message: bba0beaced7ea954ceb980f2b022ffee
|
||||
kryGs%2B/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
kryGs%2B/translation: bba0beaced7ea954ceb980f2b022ffee
|
||||
sU2uWc/message: 3fe52244676faf64e5266b6713d01e07
|
||||
sU2uWc/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
sU2uWc/translation: 3fe52244676faf64e5266b6713d01e07
|
||||
fEY2vP/message: 91509e2f92b0b3b11330b6983139fdbf
|
||||
fEY2vP/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
fEY2vP/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
@@ -541,8 +557,9 @@ checksums:
|
||||
ZH7TVS/origin/0/0: 984122104bcc6d8fa6d4c8a35e7751d2
|
||||
ZH7TVS/translation: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
||||
rQXTmd/message: 9387a4702b302f23f2f7c20b73a2b3dd
|
||||
rQXTmd/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
rQXTmd/origin/1/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
rQXTmd/origin/0/0: fc0c18a095a68277a0f90a115942683b
|
||||
rQXTmd/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||
rQXTmd/origin/2/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
rQXTmd/translation: 9387a4702b302f23f2f7c20b73a2b3dd
|
||||
VhMDMg/message: a552fc5c4189ebc3e2e6018edda7d18f
|
||||
VhMDMg/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
@@ -550,9 +567,9 @@ checksums:
|
||||
VhMDMg/origin/2/0: 0627e0040ca4939c9a57349e98c51797
|
||||
VhMDMg/origin/3/0: 0627e0040ca4939c9a57349e98c51797
|
||||
VhMDMg/translation: a552fc5c4189ebc3e2e6018edda7d18f
|
||||
fzGyWs/translation: 25dbbca7a83f6af119179e7a56c953d4
|
||||
fzGyWs/message: 25dbbca7a83f6af119179e7a56c953d4
|
||||
fzGyWs/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
fzGyWs/translation: 25dbbca7a83f6af119179e7a56c953d4
|
||||
yD3ZSw/message: 293d49fc3c75e9c425b64bd7126e6b46
|
||||
yD3ZSw/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
yD3ZSw/translation: 293d49fc3c75e9c425b64bd7126e6b46
|
||||
@@ -575,6 +592,9 @@ checksums:
|
||||
EH8IL3/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
EH8IL3/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
EH8IL3/translation: 6f79129c8f08ee54d858a2af57d16dd9
|
||||
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
|
||||
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
|
||||
VHS0aJ/message: 31d0962985c83a29558c98a765bb1b16
|
||||
VHS0aJ/origin/0/0: 15792234f408027822ee71ccddde6f9b
|
||||
VHS0aJ/translation: 31d0962985c83a29558c98a765bb1b16
|
||||
@@ -608,9 +628,8 @@ checksums:
|
||||
dtQIWT/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
dtQIWT/translation: 9d2308d578ec21bd22374e880fe3bd3c
|
||||
AUYALh/message: ee2b0671e00972773210c5be5a9ccb89
|
||||
AUYALh/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
AUYALh/origin/1/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
AUYALh/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
AUYALh/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
AUYALh/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
AUYALh/translation: ee2b0671e00972773210c5be5a9ccb89
|
||||
VbyRUy/message: 502237cd1044491c48494aa60967a985
|
||||
VbyRUy/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
@@ -680,6 +699,10 @@ checksums:
|
||||
AX4ktp/message: 4e6be22ade0188f408471325fe363575
|
||||
AX4ktp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
AX4ktp/translation: 4e6be22ade0188f408471325fe363575
|
||||
xGVfLh/message: 3cfba90b4600131e82fc4260c568d044
|
||||
xGVfLh/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
xGVfLh/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
xGVfLh/translation: 3cfba90b4600131e82fc4260c568d044
|
||||
J2Wbjt/message: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
J2Wbjt/origin/0/0: d96af8a06c2e38a72238bcd16e426859
|
||||
J2Wbjt/translation: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
@@ -699,6 +722,18 @@ checksums:
|
||||
F0YmUY/message: 9def232c9163d109f0e9093684cfe75f
|
||||
F0YmUY/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
F0YmUY/translation: 9def232c9163d109f0e9093684cfe75f
|
||||
0utuU6/message: 9d7e453b24dfaf217352aeadc262074d
|
||||
0utuU6/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
0utuU6/translation: 9d7e453b24dfaf217352aeadc262074d
|
||||
fDQ7hA/message: 1d24ecbad9f2f0081ab51c7366e40506
|
||||
fDQ7hA/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
fDQ7hA/translation: 1d24ecbad9f2f0081ab51c7366e40506
|
||||
1bJKhj/message: f61803ac8da8253e4ba35f92b3af1101
|
||||
1bJKhj/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
1bJKhj/translation: f61803ac8da8253e4ba35f92b3af1101
|
||||
uvH2xS/message: ecc2617544a6d385d18678577fa112fd
|
||||
uvH2xS/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
uvH2xS/translation: ecc2617544a6d385d18678577fa112fd
|
||||
9PaIxv/message: da95932e7a1465a5d21aa3b855a46dc2
|
||||
9PaIxv/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
9PaIxv/translation: da95932e7a1465a5d21aa3b855a46dc2
|
||||
@@ -795,9 +830,11 @@ checksums:
|
||||
7RpHg%2F/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
7RpHg%2F/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||
7RpHg%2F/translation: 66f702512197d25cfdb117f2415ecd88
|
||||
ZDPJBc/message: f9154d0c02a7db907f0a33fb5edd4be1
|
||||
ZDPJBc/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
ZDPJBc/translation: f9154d0c02a7db907f0a33fb5edd4be1
|
||||
j9IZZ0/message: 7ba841d0946eb04fa3d17d74365be37b
|
||||
j9IZZ0/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
j9IZZ0/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
j9IZZ0/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
j9IZZ0/translation: 7ba841d0946eb04fa3d17d74365be37b
|
||||
OwneeH/message: e97476d280d4ebfd196d6c5336f3511f
|
||||
OwneeH/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
@@ -838,7 +875,8 @@ checksums:
|
||||
tmpW%2Bw/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
tmpW%2Bw/translation: e915cd160651ad4b61a67e8da0737c23
|
||||
nabda1/message: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||
nabda1/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
nabda1/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
nabda1/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||
nabda1/translation: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||
njJFtc/message: a3dae0c9ddbc73677aad9812e3dffd69
|
||||
njJFtc/origin/0/0: b2059fa4b8b99cc10440da26475b234c
|
||||
@@ -919,12 +957,15 @@ checksums:
|
||||
vUr5JT/origin/0/0: 4a1be3707d14c696a16f453d132fb248
|
||||
vUr5JT/translation: 6ae8282ab8b4a01b4ce87b119e13714b
|
||||
DPfwMq/message: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
DPfwMq/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
DPfwMq/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
DPfwMq/origin/2/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
DPfwMq/origin/0/0: 81b173297e9c022d9ebde5f7018c95f9
|
||||
DPfwMq/origin/1/0: 120e67abaa784f3adb192e7519919a81
|
||||
DPfwMq/origin/2/0: fc68b5394b9b466d5d67df855060544d
|
||||
DPfwMq/origin/3/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
DPfwMq/origin/4/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
DPfwMq/origin/5/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
DPfwMq/origin/6/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
DPfwMq/origin/7/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
DPfwMq/origin/8/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
DPfwMq/translation: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
R3FsF4/message: 142b391cca3e05f2af9617092f7358c5
|
||||
R3FsF4/origin/0/0: 3a89a46ac678e34dc8eb687b07a4ad4b
|
||||
@@ -946,6 +987,16 @@ checksums:
|
||||
zxKs%2By/message: 0b6c8ad7aba0873b7e212d5f1949ca97
|
||||
zxKs%2By/origin/0/0: db1e755dc7d73e037f425111bf7c30a6
|
||||
zxKs%2By/translation: 0b6c8ad7aba0873b7e212d5f1949ca97
|
||||
euc6Ns/message: 27756566785c2b8463e21582c4bb619b
|
||||
euc6Ns/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
euc6Ns/translation: 27756566785c2b8463e21582c4bb619b
|
||||
BTDPLo/message: f6a33afcf5ff1489551c94867e083dca
|
||||
BTDPLo/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
BTDPLo/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
BTDPLo/translation: f6a33afcf5ff1489551c94867e083dca
|
||||
06EQqT/message: e9f56489fb395a890ae2557200c29a36
|
||||
06EQqT/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
06EQqT/translation: e9f56489fb395a890ae2557200c29a36
|
||||
ePK91l/message: eee7f39ff90b18852afc1671f21fbaa9
|
||||
ePK91l/origin/0/0: b697a20a9536be4dd8ecd143461eafc7
|
||||
ePK91l/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
@@ -992,6 +1043,12 @@ checksums:
|
||||
IqjpYe/message: 81d41cf573a7109c376d30d905beb596
|
||||
IqjpYe/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
IqjpYe/translation: 81d41cf573a7109c376d30d905beb596
|
||||
bFREhu/message: b2cc9a311fc28d1594252f697c7f5342
|
||||
bFREhu/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
bFREhu/translation: b2cc9a311fc28d1594252f697c7f5342
|
||||
SUXBxp/message: 76e841ae8ec3340d7d0a5c2191abbea4
|
||||
SUXBxp/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
SUXBxp/translation: 76e841ae8ec3340d7d0a5c2191abbea4
|
||||
0%2BewPp/message: 785fe23c0eef0a5b60b5b2a88151de31
|
||||
0%2BewPp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
0%2BewPp/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
@@ -1170,17 +1227,19 @@ checksums:
|
||||
l31EoQ/message: b4c05c9174637f4da2a41ead0d65bc01
|
||||
l31EoQ/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
|
||||
l31EoQ/translation: b4c05c9174637f4da2a41ead0d65bc01
|
||||
q3il6U/translation: 79a4fffd70bfbedb6174ea4c767f4ab9
|
||||
q3il6U/message: 79a4fffd70bfbedb6174ea4c767f4ab9
|
||||
q3il6U/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
q3il6U/translation: 79a4fffd70bfbedb6174ea4c767f4ab9
|
||||
"%2B3TYXa/message": 9fa1c942feb40d6350322b71561df789
|
||||
"%2B3TYXa/origin/0/0": 4980d8e27a628e7cd9a70b839d844083
|
||||
"%2B3TYXa/translation": 9fa1c942feb40d6350322b71561df789
|
||||
2POOFK/message: 0326365539c004f6088656f692602078
|
||||
2POOFK/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
2POOFK/origin/1/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
2POOFK/origin/2/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
2POOFK/origin/3/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
2POOFK/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
2POOFK/origin/1/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
2POOFK/origin/2/0: c0b59968b2b234167c488a05cc38710b
|
||||
2POOFK/origin/3/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
2POOFK/origin/4/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
2POOFK/origin/5/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
2POOFK/translation: 0326365539c004f6088656f692602078
|
||||
uP4V6I/message: 2d403501f0488ec7d4eac979faf051a2
|
||||
uP4V6I/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
@@ -1270,6 +1329,9 @@ checksums:
|
||||
csFbe%2B/message: e4e58e33d637282d141df466d729bc7c
|
||||
csFbe%2B/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
csFbe%2B/translation: e4e58e33d637282d141df466d729bc7c
|
||||
SUvm1Y/message: 6c0f26526f77e9abb92fb2b1106a66db
|
||||
SUvm1Y/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
SUvm1Y/translation: 6c0f26526f77e9abb92fb2b1106a66db
|
||||
cdyS7J/message: 0f10050aeed2555a34fe3c27ce616607
|
||||
cdyS7J/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
cdyS7J/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||
@@ -1426,9 +1488,10 @@ checksums:
|
||||
h7MgpO/origin/0/0: feacdaf2791aaddec128f463ffa2e807
|
||||
h7MgpO/translation: ef00d7494b69def6841620bd6554d040
|
||||
h8DugX/message: 6f15627a90002323eac018274b6922d6
|
||||
h8DugX/origin/0/0: db1e755dc7d73e037f425111bf7c30a6
|
||||
h8DugX/origin/1/0: 984122104bcc6d8fa6d4c8a35e7751d2
|
||||
h8DugX/origin/2/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
h8DugX/origin/0/0: 120e67abaa784f3adb192e7519919a81
|
||||
h8DugX/origin/1/0: db1e755dc7d73e037f425111bf7c30a6
|
||||
h8DugX/origin/2/0: 984122104bcc6d8fa6d4c8a35e7751d2
|
||||
h8DugX/origin/3/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
h8DugX/translation: 6f15627a90002323eac018274b6922d6
|
||||
AZI%2Fwb/message: e1ceda0c6cc32fb04cb9423582ad8fe4
|
||||
AZI%2Fwb/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
@@ -1439,13 +1502,12 @@ checksums:
|
||||
vXIe7J/message: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
vXIe7J/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
vXIe7J/translation: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
k7rCa%2F/translation: 890b7851af05f97ac53644e4154c13b4
|
||||
k7rCa%2F/message: 890b7851af05f97ac53644e4154c13b4
|
||||
k7rCa%2F/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
|
||||
k7rCa%2F/translation: 890b7851af05f97ac53644e4154c13b4
|
||||
8Is1ih/message: 624592ba2ecf647a3fe60d493c1ee71a
|
||||
8Is1ih/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
8Is1ih/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
8Is1ih/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
8Is1ih/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
8Is1ih/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
8Is1ih/translation: 624592ba2ecf647a3fe60d493c1ee71a
|
||||
EEHmPC/message: 8c1be80f5b2fcc112922282afbe30287
|
||||
EEHmPC/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
@@ -1473,12 +1535,14 @@ checksums:
|
||||
TCt%2F8K/translation: ffd8033392a01d7b33f13afa51bccb9a
|
||||
edWbV6/message: 4a4805700b31ec65bcb3ca962bccfd1b
|
||||
edWbV6/origin/0/0: 85d0a3237fa9329fba3f0278a2cd7284
|
||||
edWbV6/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||
edWbV6/origin/2/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
edWbV6/origin/3/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
edWbV6/origin/1/0: fc0c18a095a68277a0f90a115942683b
|
||||
edWbV6/origin/2/0: 1b05beba6bdd908015fa8e798717f024
|
||||
edWbV6/origin/3/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
edWbV6/origin/4/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
edWbV6/translation: 4a4805700b31ec65bcb3ca962bccfd1b
|
||||
2BBAbc/message: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||
2BBAbc/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
2BBAbc/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
2BBAbc/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
2BBAbc/translation: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||
zQX5Dj/message: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||
zQX5Dj/origin/0/0: 5e445d66b146e0ca392faa35098701c4
|
||||
@@ -1516,6 +1580,10 @@ checksums:
|
||||
DbZgsJ/message: 7a1c63b75cea690b3fda6c3f203e31ff
|
||||
DbZgsJ/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
DbZgsJ/translation: 7a1c63b75cea690b3fda6c3f203e31ff
|
||||
hB02vO/message: c13aa2a155ab4aff1c8fed6650a94e1a
|
||||
hB02vO/origin/0/0: fc68b5394b9b466d5d67df855060544d
|
||||
hB02vO/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
hB02vO/translation: c13aa2a155ab4aff1c8fed6650a94e1a
|
||||
IDvohQ/message: 35d0822f65971b97774b962561b02649
|
||||
IDvohQ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
IDvohQ/translation: 35d0822f65971b97774b962561b02649
|
||||
@@ -1523,9 +1591,12 @@ checksums:
|
||||
jl3aEJ/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
|
||||
jl3aEJ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
jl3aEJ/translation: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||
agPptk/translation: 43983f2178fb28aacaef6c5f41282c4a
|
||||
vo2a%2Ba/message: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
vo2a%2Ba/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
vo2a%2Ba/translation: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
agPptk/message: 43983f2178fb28aacaef6c5f41282c4a
|
||||
agPptk/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
|
||||
agPptk/translation: 43983f2178fb28aacaef6c5f41282c4a
|
||||
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
|
||||
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
|
||||
@@ -1553,12 +1624,19 @@ checksums:
|
||||
hty0d5/origin/0/0: 04c86ea96f180745739d59e822da4026
|
||||
hty0d5/translation: b4b1c8396e445e8b7956c747da2cf601
|
||||
"%2B8Nek%2F/message": 818f1192e32bb855597f930d3e78806e
|
||||
"%2B8Nek%2F/origin/0/0": 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
"%2B8Nek%2F/origin/1/0": 82a5cf8d69f098641eb357bf102cd504
|
||||
"%2B8Nek%2F/origin/0/0": 15ea2dfd5eae4e05b226b1320103476e
|
||||
"%2B8Nek%2F/origin/1/0": 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
"%2B8Nek%2F/origin/2/0": 82a5cf8d69f098641eb357bf102cd504
|
||||
"%2B8Nek%2F/translation": 818f1192e32bb855597f930d3e78806e
|
||||
vneRvS/message: e228107df80015377112d41f4f155cb3
|
||||
vneRvS/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
vneRvS/translation: e228107df80015377112d41f4f155cb3
|
||||
51UCsN/message: ce14fde5e972c2b5276c25ddded817c4
|
||||
51UCsN/origin/0/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
51UCsN/translation: ce14fde5e972c2b5276c25ddded817c4
|
||||
J4%2BOTA/message: 6ad27e803483208b5313660b7b5791d3
|
||||
J4%2BOTA/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
|
||||
J4%2BOTA/translation: 6ad27e803483208b5313660b7b5791d3
|
||||
BOqTi5/message: 11edf0427766c26db541d46379dc3c16
|
||||
BOqTi5/placeholders/0/0: 9db68a4b386b9fa1fd1d5797c32be100
|
||||
BOqTi5/placeholders/1/0: b837a9e8bd06b58be95699b3ee663f43
|
||||
@@ -1786,6 +1864,9 @@ checksums:
|
||||
8RdLDU/message: d7820b1bf4efecc61ed89234567aaa9c
|
||||
8RdLDU/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
8RdLDU/translation: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
|
||||
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
|
||||
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
|
||||
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
@@ -1858,25 +1939,33 @@ checksums:
|
||||
tyHiOa/origin/36/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
tyHiOa/origin/37/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
tyHiOa/origin/38/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
tyHiOa/origin/39/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
tyHiOa/origin/40/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
tyHiOa/origin/41/0: 83911e3eacbad4583e2b1647a784d154
|
||||
tyHiOa/origin/42/0: d465da67c0a870d198eb7f313569c137
|
||||
tyHiOa/origin/43/0: 33bf266228f835e12c0bfb0e5efb3be9
|
||||
tyHiOa/origin/44/0: ff48991e314b0b3e989a4dac59860fd5
|
||||
tyHiOa/origin/45/0: 30791e26a172d47d3cb0693e82679246
|
||||
tyHiOa/origin/46/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
tyHiOa/origin/47/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
tyHiOa/origin/39/0: 69d8634b177fdf937764b406fd477547
|
||||
tyHiOa/origin/40/0: 69d8634b177fdf937764b406fd477547
|
||||
tyHiOa/origin/41/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
tyHiOa/origin/42/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
tyHiOa/origin/43/0: 83911e3eacbad4583e2b1647a784d154
|
||||
tyHiOa/origin/44/0: d465da67c0a870d198eb7f313569c137
|
||||
tyHiOa/origin/45/0: 33bf266228f835e12c0bfb0e5efb3be9
|
||||
tyHiOa/origin/46/0: ff48991e314b0b3e989a4dac59860fd5
|
||||
tyHiOa/origin/47/0: 30791e26a172d47d3cb0693e82679246
|
||||
tyHiOa/origin/48/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
tyHiOa/origin/49/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
tyHiOa/translation: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
PZCqeW/message: 325dea6dd0348a27a6818db2c1340c98
|
||||
PZCqeW/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
PZCqeW/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
PZCqeW/translation: 325dea6dd0348a27a6818db2c1340c98
|
||||
fuwKpE/message: 346a18a54564881c888b95873d03df44
|
||||
fuwKpE/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/1/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
fuwKpE/origin/2/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
fuwKpE/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
fuwKpE/origin/1/0: fd0e66cef8d46e96bcf3143a12f9b6db
|
||||
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
|
||||
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
|
||||
fuwKpE/origin/4/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
fuwKpE/origin/5/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
fuwKpE/translation: 346a18a54564881c888b95873d03df44
|
||||
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
|
||||
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
nYNMZZ/translation: d5a8a1b38eaf02d02cd6ac2211b42a92
|
||||
aHCEmh/message: ce27f1aeacccc542a174c4b2bce022b0
|
||||
aHCEmh/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
|
||||
aHCEmh/origin/1/0: 634473ef965836871efe40875a36b6ab
|
||||
@@ -1898,8 +1987,9 @@ checksums:
|
||||
zwBp5t/origin/0/0: d6920b20329a9efe5985a39a295d83e1
|
||||
zwBp5t/translation: d740d0660e68c61db1853489356030ae
|
||||
3fPjUY/message: 682b3c9feab30112b4454cb5bb7974b1
|
||||
3fPjUY/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
3fPjUY/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
3fPjUY/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
3fPjUY/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||
3fPjUY/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
3fPjUY/translation: 682b3c9feab30112b4454cb5bb7974b1
|
||||
oyRjD%2F/message: 76a09fd843b496610f8cf1dced417b62
|
||||
oyRjD%2F/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
@@ -2009,6 +2099,7 @@ checksums:
|
||||
"%2FgaSVU/origin/0/0": 98e2302d0d8d12ce65c4140b87450673
|
||||
"%2FgaSVU/origin/1/0": 634473ef965836871efe40875a36b6ab
|
||||
"%2FgaSVU/origin/2/0": 634473ef965836871efe40875a36b6ab
|
||||
"%2FgaSVU/origin/3/0": 69d8634b177fdf937764b406fd477547
|
||||
"%2FgaSVU/translation": c60f4a1acf30e566861bf130f13b9ae7
|
||||
GDvlUT/message: 53743bbb6ca938f5b893552e839d067f
|
||||
GDvlUT/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
@@ -2057,6 +2148,9 @@ checksums:
|
||||
K3iLeO/message: e3bf6f95419169f460fb6918a0afc512
|
||||
K3iLeO/origin/0/0: 5e2ed4920ffd2f535ece848ff44900bd
|
||||
K3iLeO/translation: e3bf6f95419169f460fb6918a0afc512
|
||||
zYRVNp/message: 8b36455dd5b43e56d2ab06e522c3bacc
|
||||
zYRVNp/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
zYRVNp/translation: 8b36455dd5b43e56d2ab06e522c3bacc
|
||||
wgNoIs/message: eedc7cdb02de467c15dc418a066a77f2
|
||||
wgNoIs/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
wgNoIs/origin/1/0: 3c57a918258af4b44c187f58d203345f
|
||||
@@ -2086,11 +2180,16 @@ checksums:
|
||||
v3YoMA/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
v3YoMA/translation: 3fff865dc00435f82896fc302ea45630
|
||||
8AbRER/message: 3cb81c4829857556f5d117c9a23f7bcc
|
||||
8AbRER/origin/0/0: 2201bf679c663a732d7f1b3c3ce3897f
|
||||
8AbRER/origin/0/0: 81b173297e9c022d9ebde5f7018c95f9
|
||||
8AbRER/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
|
||||
8AbRER/origin/2/0: 2201bf679c663a732d7f1b3c3ce3897f
|
||||
8AbRER/translation: 3cb81c4829857556f5d117c9a23f7bcc
|
||||
Eit43O/message: b5d9a1edd988f455041aee9fa7fb3d63
|
||||
Eit43O/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
Eit43O/translation: b5d9a1edd988f455041aee9fa7fb3d63
|
||||
JjEJSy/message: 0154ff378b3f4329871378229e269fc7
|
||||
JjEJSy/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
JjEJSy/translation: 0154ff378b3f4329871378229e269fc7
|
||||
Tz0i8g/message: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
Tz0i8g/origin/0/0: 77c9f6139024b2069e4fdc5120f69cb5
|
||||
Tz0i8g/origin/1/0: 94d3e20c29bb517dd394711d7524e478
|
||||
@@ -2125,6 +2224,10 @@ checksums:
|
||||
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
|
||||
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
|
||||
fcWrnU/message: 6d842546a84e919c519bf8cbffdf25a0
|
||||
fcWrnU/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
fcWrnU/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
fcWrnU/translation: 6d842546a84e919c519bf8cbffdf25a0
|
||||
mErq7F/message: 0dd2ae69be4618c1f9e615774a4509ca
|
||||
mErq7F/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
mErq7F/translation: 0dd2ae69be4618c1f9e615774a4509ca
|
||||
@@ -2151,15 +2254,18 @@ checksums:
|
||||
zEcnBA/message: 513c3b4d9f0fda60fe079d42dcbd3882
|
||||
zEcnBA/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
zEcnBA/translation: 513c3b4d9f0fda60fe079d42dcbd3882
|
||||
4Revpc/translation: 3adb3ae7891c5736776df840cff2360b
|
||||
4Revpc/message: 3adb3ae7891c5736776df840cff2360b
|
||||
4Revpc/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
|
||||
4Revpc/translation: 3adb3ae7891c5736776df840cff2360b
|
||||
"%2B%2BrVAm/message": 2227508afb0d38a027e323207e47d4a2
|
||||
"%2B%2BrVAm/origin/0/0": eb63312c63f6c8d2a5b6520c89012123
|
||||
"%2B%2BrVAm/translation": 2227508afb0d38a027e323207e47d4a2
|
||||
wIb7Ng/message: 67659097ef6bcb194839858b2c4f3a3c
|
||||
wIb7Ng/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
wIb7Ng/translation: 67659097ef6bcb194839858b2c4f3a3c
|
||||
6sKjjx/message: 50861c0881fe87a77412417e5ffb3fbb
|
||||
6sKjjx/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
6sKjjx/translation: 50861c0881fe87a77412417e5ffb3fbb
|
||||
vnS6Rf/message: 003f7f93d44b0f5c2e40bf24e7af3bd7
|
||||
vnS6Rf/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
vnS6Rf/translation: 003f7f93d44b0f5c2e40bf24e7af3bd7
|
||||
@@ -2199,6 +2305,9 @@ checksums:
|
||||
D%2BNlUC/message: 803281c327a02a2feba182f4a2b38311
|
||||
D%2BNlUC/origin/0/0: 358ea1b5babb2e9ccbf1a4c9f89dd3a5
|
||||
D%2BNlUC/translation: 803281c327a02a2feba182f4a2b38311
|
||||
KM6m8p/message: c621ea9404a37af289def443b309bf1b
|
||||
KM6m8p/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
|
||||
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
@@ -2250,6 +2359,9 @@ checksums:
|
||||
C6gv54/message: 1573c77059bc92df17e661befda8d9b7
|
||||
C6gv54/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
C6gv54/translation: 1573c77059bc92df17e661befda8d9b7
|
||||
nKBUeF/message: 07e3ce10820b8ab28643e38b2ed51f64
|
||||
nKBUeF/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
nKBUeF/translation: 07e3ce10820b8ab28643e38b2ed51f64
|
||||
jlB2RY/message: 67a76bf346ab4b48563269e9d941a64a
|
||||
jlB2RY/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
jlB2RY/translation: 67a76bf346ab4b48563269e9d941a64a
|
||||
@@ -2301,6 +2413,12 @@ checksums:
|
||||
vlxQFL/message: 730f33b8f1fc002c4f393ccd262b6664
|
||||
vlxQFL/origin/0/0: afee530613110e248ffde6b56872ebed
|
||||
vlxQFL/translation: 730f33b8f1fc002c4f393ccd262b6664
|
||||
HQVm6e/message: ec66b543a593e9dabf47de5979892d8d
|
||||
HQVm6e/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
HQVm6e/translation: ec66b543a593e9dabf47de5979892d8d
|
||||
gKmoow/message: 83997c77cb0dc8c8c90f7ab706aa817f
|
||||
gKmoow/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
gKmoow/translation: 83997c77cb0dc8c8c90f7ab706aa817f
|
||||
OAYToL/message: 1c989752736fa41ecdd68ea890f16a15
|
||||
OAYToL/origin/0/0: 15792234f408027822ee71ccddde6f9b
|
||||
OAYToL/translation: 1c989752736fa41ecdd68ea890f16a15
|
||||
@@ -2322,6 +2440,9 @@ checksums:
|
||||
MHrjPM/message: 344e64395eaff6822a57d18623853e1a
|
||||
MHrjPM/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
MHrjPM/translation: 344e64395eaff6822a57d18623853e1a
|
||||
wK4OTM/message: b2a89bfd8e13d77f626e84840602a603
|
||||
wK4OTM/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
wK4OTM/translation: b2a89bfd8e13d77f626e84840602a603
|
||||
Se%2FUIp/message: d60813ea824f373462471e092d136eed
|
||||
Se%2FUIp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
Se%2FUIp/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
@@ -2364,9 +2485,10 @@ checksums:
|
||||
LeM5RY/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
LeM5RY/translation: dfeac280858332082ad34d2623a59863
|
||||
W1ewR0/message: ddcac5d14d48358b043a9487b42eec16
|
||||
W1ewR0/origin/0/0: 1b05beba6bdd908015fa8e798717f024
|
||||
W1ewR0/origin/1/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
W1ewR0/origin/2/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
W1ewR0/origin/0/0: fc0c18a095a68277a0f90a115942683b
|
||||
W1ewR0/origin/1/0: 1b05beba6bdd908015fa8e798717f024
|
||||
W1ewR0/origin/2/0: 9c8b544f5db130cd7c5eda9064ba3957
|
||||
W1ewR0/origin/3/0: faa8a2576972b4eb31565d8ea6098f43
|
||||
W1ewR0/translation: ddcac5d14d48358b043a9487b42eec16
|
||||
zl1b97/message: 90112ea12ec6fa42097a6e022ae048a4
|
||||
zl1b97/origin/0/0: 984122104bcc6d8fa6d4c8a35e7751d2
|
||||
@@ -2383,6 +2505,7 @@ checksums:
|
||||
YcyP2z/translation: 7495f1434c9188849bffd015811d5337
|
||||
MxQXhL/message: c1c949477e121d73ed6683eddf9cd874
|
||||
MxQXhL/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
MxQXhL/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
MxQXhL/translation: c1c949477e121d73ed6683eddf9cd874
|
||||
WHHLh9/message: 99d0b17b78d44e15f13d876d155f0efc
|
||||
WHHLh9/origin/0/0: 3a89a46ac678e34dc8eb687b07a4ad4b
|
||||
@@ -2399,6 +2522,12 @@ checksums:
|
||||
2QGEbi/message: 550198b2c87f06726a843c79c1026ed9
|
||||
2QGEbi/origin/0/0: 4b4f55c033add8db69178563832227c6
|
||||
2QGEbi/translation: 550198b2c87f06726a843c79c1026ed9
|
||||
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
|
||||
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
|
||||
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
|
||||
K7k9u3/message: 1cf9d227042f6a65adba38cb7296830d
|
||||
K7k9u3/origin/0/0: fd0e66cef8d46e96bcf3143a12f9b6db
|
||||
K7k9u3/translation: 1cf9d227042f6a65adba38cb7296830d
|
||||
8yFGGF/message: 39025a0c53818438829603d213baef06
|
||||
8yFGGF/origin/0/0: 68450df287fb46ea629e818732c1291a
|
||||
8yFGGF/translation: 39025a0c53818438829603d213baef06
|
||||
@@ -2411,6 +2540,9 @@ checksums:
|
||||
A8jqN7/message: 656c93265d7e2bef1245b17d85f85ae5
|
||||
A8jqN7/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
|
||||
A8jqN7/translation: 656c93265d7e2bef1245b17d85f85ae5
|
||||
Q60WUZ/message: 4284c33aed2f2c5e004ed0a7d7d9bf87
|
||||
Q60WUZ/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
Q60WUZ/translation: 4284c33aed2f2c5e004ed0a7d7d9bf87
|
||||
T7Mywq/message: 62899897d7bffcb1df3af77d460f0881
|
||||
T7Mywq/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
T7Mywq/translation: 62899897d7bffcb1df3af77d460f0881
|
||||
@@ -2497,12 +2629,14 @@ checksums:
|
||||
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
|
||||
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
|
||||
mx8%2B1u/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
mx8%2B1u/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
|
||||
mx8%2B1u/origin/2/0: c0b59968b2b234167c488a05cc38710b
|
||||
mx8%2B1u/origin/3/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
mx8%2B1u/origin/4/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
mx8%2B1u/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
mx8%2B1u/origin/3/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
mx8%2B1u/translation: f2949ca2dc18b0063af92e60779abb65
|
||||
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
|
||||
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
|
||||
Ws%2B3X%2B/message: 905b10eeb09fdc5ac7fc757e935ee757
|
||||
Ws%2B3X%2B/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
Ws%2B3X%2B/translation: 905b10eeb09fdc5ac7fc757e935ee757
|
||||
@@ -2542,7 +2676,7 @@ checksums:
|
||||
IelE1h/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
IelE1h/translation: 972773025e763ddf53273df6d37a729a
|
||||
cWtfn1/message: 9a91ea70f5eb55aac96640bf0fd7feff
|
||||
cWtfn1/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
cWtfn1/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
cWtfn1/translation: 9a91ea70f5eb55aac96640bf0fd7feff
|
||||
b3Thhd/message: 5b7b755f05330164dbfb00096c500ee3
|
||||
b3Thhd/origin/0/0: 12c1fab543c8848bcb10107d47336736
|
||||
@@ -2674,7 +2808,8 @@ checksums:
|
||||
UlhdTP/origin/0/0: 83911e3eacbad4583e2b1647a784d154
|
||||
UlhdTP/translation: bc5c267ce3f04de94ff1379cf2ca48a3
|
||||
chyf4J/message: 7159d620b7ef5c5afef734989a5be08b
|
||||
chyf4J/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
chyf4J/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
chyf4J/origin/1/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
chyf4J/translation: 7159d620b7ef5c5afef734989a5be08b
|
||||
Ax%2FhYa/message: d0e83b9a0304d7c36cf8c85d98783ce2
|
||||
Ax%2FhYa/origin/0/0: 33bf266228f835e12c0bfb0e5efb3be9
|
||||
@@ -2692,7 +2827,8 @@ checksums:
|
||||
wtxjAY/translation: 1857292f9567c556c781a23bd7e10544
|
||||
CozWO1/message: 855614bbf862fd6a9f6a3e76943f92d4
|
||||
CozWO1/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
CozWO1/origin/1/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
CozWO1/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
CozWO1/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
CozWO1/translation: 855614bbf862fd6a9f6a3e76943f92d4
|
||||
ghSJ4U/message: 681f86c9ca31699fc5a3ca3790e5972c
|
||||
ghSJ4U/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
@@ -2735,6 +2871,9 @@ checksums:
|
||||
RtlIYB/message: df4783d593505766d78c5bc08d3d48ac
|
||||
RtlIYB/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
RtlIYB/translation: df4783d593505766d78c5bc08d3d48ac
|
||||
3WXdoZ/message: 4cbeab40f77975492a23eec428732440
|
||||
3WXdoZ/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
3WXdoZ/translation: 4cbeab40f77975492a23eec428732440
|
||||
aelko%2B/message: 41dd145ef56f539e12dc064a9807c660
|
||||
aelko%2B/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
|
||||
aelko%2B/translation: 41dd145ef56f539e12dc064a9807c660
|
||||
@@ -2778,6 +2917,9 @@ checksums:
|
||||
GoDLB9/message: 8c8d944e07388c5877effdb2c2803dcf
|
||||
GoDLB9/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
GoDLB9/translation: 8c8d944e07388c5877effdb2c2803dcf
|
||||
c4nMcR/message: bf4f697079eef51c3ebe46ce79063895
|
||||
c4nMcR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
|
||||
c4nMcR/translation: bf4f697079eef51c3ebe46ce79063895
|
||||
evg7%2BA/message: 403972e7a25afc2415762c1c2b1ec868
|
||||
evg7%2BA/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
evg7%2BA/translation: 403972e7a25afc2415762c1c2b1ec868
|
||||
@@ -2814,6 +2956,12 @@ checksums:
|
||||
25fAUC/message: 3e4cce96bc1197a1caa2b7fb4d3c7e53
|
||||
25fAUC/origin/0/0: d1e1d7ccce60e05570bedfbb93fc9416
|
||||
25fAUC/translation: 3e4cce96bc1197a1caa2b7fb4d3c7e53
|
||||
GRAGsB/message: 751a534fd6e62a92ff64c46894c7db86
|
||||
GRAGsB/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
GRAGsB/translation: 751a534fd6e62a92ff64c46894c7db86
|
||||
j0o6ml/message: 1e036ffca9d9cb9e2941b83fbbf375c0
|
||||
j0o6ml/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
j0o6ml/translation: 1e036ffca9d9cb9e2941b83fbbf375c0
|
||||
GnSSGm/message: 98de6f65c567c6654cc0adce3c9d5656
|
||||
GnSSGm/origin/0/0: 33bf266228f835e12c0bfb0e5efb3be9
|
||||
GnSSGm/translation: 98de6f65c567c6654cc0adce3c9d5656
|
||||
@@ -2826,6 +2974,10 @@ checksums:
|
||||
Hg1qE9/message: c808949b9b2b4a9aba2472f5d1050167
|
||||
Hg1qE9/origin/0/0: 30791e26a172d47d3cb0693e82679246
|
||||
Hg1qE9/translation: c808949b9b2b4a9aba2472f5d1050167
|
||||
OTxjpR/message: 370a32c49489db61978ac91b55699c24
|
||||
OTxjpR/origin/0/0: 69d8634b177fdf937764b406fd477547
|
||||
OTxjpR/origin/1/0: 69d8634b177fdf937764b406fd477547
|
||||
OTxjpR/translation: 370a32c49489db61978ac91b55699c24
|
||||
4DOCMI/message: 0b48896061a1124501fdaba026804148
|
||||
4DOCMI/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
4DOCMI/translation: 0b48896061a1124501fdaba026804148
|
||||
|
||||
@@ -28,6 +28,7 @@ const config = {
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
transpilePackages: [
|
||||
"@kan/api",
|
||||
@@ -65,6 +66,8 @@ const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
serverExternalPackages: ["pino"],
|
||||
|
||||
experimental: {
|
||||
// instrumentationHook: true,
|
||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
@@ -43,6 +45,7 @@ export default function Dashboard({
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||
@@ -98,9 +101,13 @@ export default function Dashboard({
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`);
|
||||
} else {
|
||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||
}
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal]);
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export interface RenderSuggestionsProps {
|
||||
export interface WorkspaceMember {
|
||||
publicId: string;
|
||||
user: {
|
||||
id: string;
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
|
||||
@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
</button>
|
||||
)}
|
||||
{iconRight && type !== "password" && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 leading-[0]">
|
||||
{iconRight}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function LabelForm({
|
||||
labelPublicId: entityId,
|
||||
},
|
||||
{
|
||||
enabled: isEdit && !!entityId,
|
||||
enabled: !!isEdit && entityId.length >= 12,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -207,7 +207,7 @@ export function LabelForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isEdit && (
|
||||
<Toggle
|
||||
label={t`Create another`}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
HiBolt,
|
||||
HiCheck,
|
||||
HiCheckBadge,
|
||||
HiInformationCircle,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
import { HiCheck, HiXMark } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -54,7 +47,6 @@ export function NewWorkspaceForm() {
|
||||
watch,
|
||||
trigger,
|
||||
clearErrors,
|
||||
setValue,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
@@ -67,16 +59,10 @@ export function NewWorkspaceForm() {
|
||||
|
||||
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
|
||||
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
|
||||
const slug = watch("slug");
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
const isTyping = slug !== debouncedSlug;
|
||||
|
||||
// Pro toggle state management
|
||||
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
|
||||
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
|
||||
|
||||
// Validate slug only after debounce
|
||||
useEffect(() => {
|
||||
if (isTyping) {
|
||||
@@ -101,7 +87,7 @@ export function NewWorkspaceForm() {
|
||||
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
||||
|
||||
const createWorkspace = api.workspace.create.useMutation({
|
||||
onSuccess: async (values, variables) => {
|
||||
onSuccess: (values) => {
|
||||
if (values.publicId && values.name) {
|
||||
void utils.workspace.all.invalidate();
|
||||
switchWorkspace({
|
||||
@@ -114,42 +100,6 @@ export function NewWorkspaceForm() {
|
||||
weekStartDay: 1,
|
||||
});
|
||||
|
||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/stripe/create_checkout_session",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: slug || undefined,
|
||||
workspacePublicId: values.publicId,
|
||||
cancelUrl: "/settings/workspace?upgrade=pro",
|
||||
successUrl: "/boards",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const url = (data as { url: string }).url;
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return; // Don't close modal if redirecting to checkout
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
showPopup({
|
||||
header: t`Error upgrading to Pro`,
|
||||
message: t`Workspace created successfully. You can upgrade later in settings.`,
|
||||
icon: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
@@ -168,57 +118,8 @@ export function NewWorkspaceForm() {
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
|
||||
|
||||
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isCloudEnv &&
|
||||
!checkWorkspaceSlugAvailability.isPending &&
|
||||
isValidSlug
|
||||
) {
|
||||
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
|
||||
setShouldShowBenefits(isAvailable);
|
||||
|
||||
// Automatically enable Pro toggle when slug is available
|
||||
if (isAvailable) {
|
||||
setIsProToggleEnabled(true);
|
||||
setLastAvailableSlug(slug);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isValidSlug,
|
||||
isWorkspaceSlugAvailable?.isAvailable,
|
||||
checkWorkspaceSlugAvailability.isPending,
|
||||
slug,
|
||||
isCloudEnv,
|
||||
]);
|
||||
|
||||
// Reset benefits when slug becomes invalid
|
||||
useEffect(() => {
|
||||
if (!isValidSlug) {
|
||||
setShouldShowBenefits(false);
|
||||
}
|
||||
}, [isValidSlug]);
|
||||
|
||||
// Handle Pro toggle changes
|
||||
const handleProToggleChange = () => {
|
||||
if (isProToggleEnabled) {
|
||||
// Turning off: clear the slug and disable Pro
|
||||
setValue("slug", "");
|
||||
setIsProToggleEnabled(false);
|
||||
} else {
|
||||
// Turning on: restore the last available slug if we have one
|
||||
if (lastAvailableSlug) {
|
||||
setValue("slug", lastAvailableSlug);
|
||||
}
|
||||
setIsProToggleEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const showProBenefits = isProToggleEnabled;
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
// Don't submit if slug is provided but not available
|
||||
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
|
||||
@@ -227,7 +128,7 @@ export function NewWorkspaceForm() {
|
||||
|
||||
createWorkspace.mutate({
|
||||
name: values.name,
|
||||
slug: !isCloudEnv && values.slug ? values.slug : undefined,
|
||||
slug: values.slug,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -290,11 +191,7 @@ export function NewWorkspaceForm() {
|
||||
? t`This workspace URL is reserved`
|
||||
: undefined)
|
||||
}
|
||||
prefix={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn/"
|
||||
: `${env("NEXT_PUBLIC_BASE_URL")}/`
|
||||
}
|
||||
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/`}
|
||||
iconRight={
|
||||
slug && slug.length >= 3 && !errors.slug ? (
|
||||
isWorkspaceSlugAvailable?.isAvailable ? (
|
||||
@@ -311,68 +208,9 @@ export function NewWorkspaceForm() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{slug && slug.length >= 3 && shouldShowBenefits && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<HiInformationCircle className="h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`Custom URLs require upgrading to a Pro plan`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProBenefits && (
|
||||
<div className="mt-6">
|
||||
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Unlimited members`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Custom workspace URL`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Board analytics`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
|
||||
{t`Coming soon`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
|
||||
!showProBenefits && "mt-12",
|
||||
)}
|
||||
>
|
||||
{/* Pro Toggle - only show in cloud environment */}
|
||||
{isCloudEnv && (
|
||||
<Toggle
|
||||
isChecked={isProToggleEnabled}
|
||||
onChange={handleProToggleChange}
|
||||
label={t`Upgrade to Pro ($29/month)`}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -7,15 +7,17 @@ const Toggle = ({
|
||||
label,
|
||||
disabled,
|
||||
showLabel = true,
|
||||
labelPosition = "before",
|
||||
}: {
|
||||
isChecked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
labelPosition?: "before" | "after";
|
||||
}) => (
|
||||
<div className="mr-4 flex items-center justify-end">
|
||||
{showLabel && (
|
||||
<div className="flex items-center">
|
||||
{showLabel && labelPosition === "before" && (
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
@@ -38,6 +40,11 @@ const Toggle = ({
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
{showLabel && labelPosition === "after" && (
|
||||
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Button, Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { Fragment, useState } from "react";
|
||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@@ -18,6 +20,7 @@ export default function WorkspaceMenu({
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
||||
@@ -147,7 +150,11 @@ export default function WorkspaceMenu({
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
onClick={() =>
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
|
||||
: openModal("NEW_WORKSPACE")
|
||||
}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
|
||||
import { de, enGB, es, fr, it, nl, pl, ptBR, ru } from "date-fns/locale";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { useLinguiContext } from "~/providers/lingui";
|
||||
@@ -19,7 +19,7 @@ export function useLocalisation() {
|
||||
nl,
|
||||
ru,
|
||||
pl,
|
||||
"pt-BR": ptBR,
|
||||
ptbr: ptBR,
|
||||
};
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,12 +1,13 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -32,7 +33,9 @@ export default withRateLimit(
|
||||
try {
|
||||
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||
} catch {
|
||||
return res.status(500).json({ message: "Storage endpoint misconfigured" });
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Storage endpoint misconfigured" });
|
||||
}
|
||||
|
||||
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||
@@ -66,10 +69,7 @@ export default withRateLimit(
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to download attachment" });
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("https://formbricks.com/api/oss-friends");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch from Formbricks");
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
return res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching OSS friends:", error);
|
||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||
}
|
||||
},
|
||||
try {
|
||||
const response = await fetch("https://formbricks.com/api/oss-friends");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch from Formbricks");
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
return res.status(200).json(data);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,34 +2,34 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user?.stripeCustomerId) {
|
||||
return res.status(404).json({ error: "No billing account found" });
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
|
||||
});
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating portal session" });
|
||||
}
|
||||
},
|
||||
if (!user?.stripeCustomerId) {
|
||||
return res.status(404).json({ error: "No billing account found" });
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
|
||||
});
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: "Error creating portal session" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3,10 +3,13 @@ import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const workspaceSlugSchema = z
|
||||
.string()
|
||||
@@ -17,21 +20,24 @@ const workspaceSlugSchema = z
|
||||
interface CheckoutSessionRequest {
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
slug: string;
|
||||
workspacePublicId: string;
|
||||
stripeCustomerId: string;
|
||||
plan: "team" | "pro";
|
||||
billing?: string;
|
||||
workspacePublicId?: string;
|
||||
slug?: string;
|
||||
workspaceName?: string;
|
||||
workspaceDescription?: string;
|
||||
workspaceSlug?: string;
|
||||
}
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
@@ -39,81 +45,107 @@ export default withRateLimit(
|
||||
}
|
||||
|
||||
const body = req.body as CheckoutSessionRequest;
|
||||
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
|
||||
const {
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
plan,
|
||||
billing,
|
||||
workspacePublicId,
|
||||
slug,
|
||||
workspaceName,
|
||||
workspaceDescription,
|
||||
workspaceSlug,
|
||||
} = body;
|
||||
|
||||
if (!successUrl || !cancelUrl || !workspacePublicId) {
|
||||
if (!successUrl || !cancelUrl || !plan) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
const slugResult = workspaceSlugSchema.safeParse(slug);
|
||||
if (!workspacePublicId && !workspaceName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Must provide workspacePublicId or workspaceName" });
|
||||
}
|
||||
|
||||
const resolvedSlug = slug ?? workspaceSlug;
|
||||
|
||||
if (resolvedSlug) {
|
||||
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug);
|
||||
if (!slugResult.success) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid workspace slug" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Invalid workspace slug" });
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
let resolvedWorkspacePublicId = workspacePublicId;
|
||||
let subscriptionId: number | undefined;
|
||||
|
||||
const isMemberOfWorkspace = workspace.some(
|
||||
({ workspace }) => workspace.publicId === body.workspacePublicId,
|
||||
);
|
||||
if (workspacePublicId) {
|
||||
// Existing workspace upgrade
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
workspacePublicId,
|
||||
);
|
||||
|
||||
if (!isMemberOfWorkspace) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
if (!workspace) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
await assertPermission(db, user.id, workspace.id, "workspace:manage");
|
||||
} catch {
|
||||
return res.status(403).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const subscription = await subscriptionRepo.create(db, {
|
||||
plan: plan,
|
||||
referenceId: workspacePublicId,
|
||||
userId: user.id,
|
||||
stripeCustomerId: user.stripeCustomerId ?? "",
|
||||
status: "incomplete",
|
||||
});
|
||||
|
||||
subscriptionId = subscription?.id;
|
||||
|
||||
if (!subscriptionId) {
|
||||
return res.status(500).json({ error: "Error creating subscription" });
|
||||
}
|
||||
} else {
|
||||
resolvedWorkspacePublicId = generateUID();
|
||||
}
|
||||
|
||||
const subscription = await subscriptionRepo.create(db, {
|
||||
plan: "pro",
|
||||
referenceId: workspacePublicId,
|
||||
userId: user.id,
|
||||
stripeCustomerId: user.stripeCustomerId ?? "",
|
||||
status: "incomplete",
|
||||
});
|
||||
|
||||
const subscriptionId = subscription?.id;
|
||||
|
||||
if (!subscriptionId) {
|
||||
return res.status(500).json({ error: "Error creating subscription" });
|
||||
}
|
||||
const isTeam = body.plan === "team";
|
||||
const annualPriceId = isTeam
|
||||
? (process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID ??
|
||||
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
|
||||
: (process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID ??
|
||||
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
|
||||
const monthlyPriceId = isTeam
|
||||
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
|
||||
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
|
||||
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_collection: "always",
|
||||
line_items: [
|
||||
{
|
||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
subscription_data: {
|
||||
trial_period_days: 14,
|
||||
},
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
subscription_data: { trial_period_days: 14 },
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
|
||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||
client_reference_id: workspacePublicId,
|
||||
client_reference_id: resolvedWorkspacePublicId,
|
||||
customer: user.stripeCustomerId ?? undefined,
|
||||
metadata: {
|
||||
...(slug && { workspaceSlug: slug }),
|
||||
workspacePublicId,
|
||||
workspacePublicId: resolvedWorkspacePublicId!,
|
||||
userId: user.id,
|
||||
subscriptionId,
|
||||
userEmail: user.email ?? "",
|
||||
...(resolvedSlug && { workspaceSlug: resolvedSlug }),
|
||||
...(workspaceName && { workspaceName }),
|
||||
...(workspaceDescription && { workspaceDescription }),
|
||||
...(subscriptionId && { subscriptionId: String(subscriptionId) }),
|
||||
plan: plan,
|
||||
isNewWorkspace: workspaceName ? "true" : "false",
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating checkout session" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
const log = createLogger("stripe-webhook");
|
||||
const log = createLogger("api");
|
||||
|
||||
async function buffer(readable: Readable) {
|
||||
const chunks = [];
|
||||
@@ -21,6 +23,9 @@ export default async function handler(
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
const start = Date.now();
|
||||
const requestId = randomUUID();
|
||||
const procedure = req.url?.split("?")[0] ?? "/api/stripe/webhook";
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
@@ -44,29 +49,85 @@ export default async function handler(
|
||||
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received");
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const checkoutSession = event.data.object;
|
||||
const meta = checkoutSession.metadata;
|
||||
|
||||
const metaData = checkoutSession.metadata;
|
||||
if (!meta?.workspacePublicId) break;
|
||||
|
||||
if (metaData?.workspacePublicId) {
|
||||
await workspaceRepo.update(db, metaData.workspacePublicId, {
|
||||
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }),
|
||||
plan: "pro",
|
||||
const plan = meta.plan === "team" ? "team" : ("pro" as const);
|
||||
|
||||
if (
|
||||
meta.isNewWorkspace === "true" &&
|
||||
meta.workspaceName &&
|
||||
meta.userId &&
|
||||
meta.userEmail
|
||||
) {
|
||||
const existing = await workspaceRepo.getByPublicId(db, meta.workspacePublicId);
|
||||
|
||||
if (!existing) {
|
||||
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
|
||||
|
||||
await workspaceRepo.create(db, {
|
||||
publicId: meta.workspacePublicId,
|
||||
name: meta.workspaceName,
|
||||
slug,
|
||||
plan,
|
||||
createdBy: meta.userId,
|
||||
createdByEmail: meta.userEmail,
|
||||
...(meta.workspaceDescription && {
|
||||
description: meta.workspaceDescription,
|
||||
}),
|
||||
});
|
||||
|
||||
await subscriptionRepo.create(db, {
|
||||
plan,
|
||||
referenceId: meta.workspacePublicId,
|
||||
userId: meta.userId,
|
||||
stripeCustomerId: checkoutSession.customer as string,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Existing workspace upgrade — update plan (and slug for pro)
|
||||
await workspaceRepo.update(db, meta.workspacePublicId, {
|
||||
plan,
|
||||
...(plan === "pro" && meta.workspaceSlug && { slug: meta.workspaceSlug }),
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
|
||||
}
|
||||
|
||||
log.info(
|
||||
{
|
||||
requestId,
|
||||
procedure,
|
||||
transport: "rest",
|
||||
duration: Date.now() - start,
|
||||
status: 200,
|
||||
input: { eventType: event.type, eventId: event.id },
|
||||
},
|
||||
"API OK",
|
||||
);
|
||||
|
||||
return res.status(200).json({ received: true });
|
||||
} catch (err) {
|
||||
log.error({ err }, "Stripe webhook handler failed");
|
||||
log.error(
|
||||
{
|
||||
requestId,
|
||||
procedure,
|
||||
transport: "rest",
|
||||
duration: Date.now() - start,
|
||||
status: 400,
|
||||
err,
|
||||
},
|
||||
"API error",
|
||||
);
|
||||
return res.status(400).json({ message: "Webhook handler failed" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,58 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { addYears } from "date-fns";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
import { addYears } from "date-fns";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { user } = await createNextApiContext(req);
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user)
|
||||
return res.status(401).json({ message: "User not authenticated" });
|
||||
if (!user)
|
||||
return res.status(401).json({ message: "User not authenticated" });
|
||||
|
||||
const apiKey = process.env.TRELLO_APP_API_KEY;
|
||||
const apiKey = process.env.TRELLO_APP_API_KEY;
|
||||
|
||||
if (!apiKey)
|
||||
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
|
||||
if (!apiKey)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Trello API key not set in Environment Variables" });
|
||||
|
||||
const token = req.body.token;
|
||||
const token = req.body.token;
|
||||
|
||||
if (!token)
|
||||
return res.status(400).json({ message: "No token found" });
|
||||
if (!token) return res.status(400).json({ message: "No token found" });
|
||||
|
||||
try {
|
||||
const { db } = await createNextApiContext(req);
|
||||
try {
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
await db.insert(integrations).values({
|
||||
provider: "trello",
|
||||
userId: user.id,
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
}).onConflictDoUpdate({
|
||||
set: {
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
},
|
||||
target: [integrations.userId, integrations.provider],
|
||||
});
|
||||
await db
|
||||
.insert(integrations)
|
||||
.values({
|
||||
provider: "trello",
|
||||
userId: user.id,
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
set: {
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
},
|
||||
target: [integrations.userId, integrations.provider],
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Trello authentication successful" });
|
||||
} catch (err) {
|
||||
console.error("Trello authentication error:", err);
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
},
|
||||
);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ message: "Trello authentication successful" });
|
||||
} catch (err) {
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Novu } from "@novu/api";
|
||||
import { jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const requestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
@@ -22,84 +24,85 @@ const textEncoder = new TextEncoder();
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Unsubscribe endpoint is not available.",
|
||||
code: "UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
withApiLogging(
|
||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Unsubscribe endpoint is not available.",
|
||||
code: "UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
error: "Method not allowed.",
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
});
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
error: "Method not allowed.",
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = requestSchema.safeParse(req.body);
|
||||
const parsedBody = requestSchema.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request payload.",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
if (!parsedBody.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request payload.",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Unsubscribe service is not configured.",
|
||||
code: "NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Unsubscribe service is not configured.",
|
||||
code: "NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
|
||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
||||
|
||||
try {
|
||||
const verified = await jwtVerify(
|
||||
parsedBody.data.token,
|
||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
||||
{
|
||||
// We intentionally do not use exp/iat claims –
|
||||
// tokens are long-lived and validated only by signature + payload.
|
||||
clockTolerance: "0s",
|
||||
},
|
||||
);
|
||||
payload = tokenPayloadSchema.parse(verified.payload);
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: "Your unsubscribe link is invalid or has expired.",
|
||||
code: "INVALID_TOKEN",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const verified = await jwtVerify(
|
||||
parsedBody.data.token,
|
||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
||||
{
|
||||
// We intentionally do not use exp/iat claims –
|
||||
// tokens are long-lived and validated only by signature + payload.
|
||||
clockTolerance: "0s",
|
||||
},
|
||||
);
|
||||
payload = tokenPayloadSchema.parse(verified.payload);
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: "Your unsubscribe link is invalid or has expired.",
|
||||
code: "INVALID_TOKEN",
|
||||
});
|
||||
}
|
||||
|
||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
||||
|
||||
try {
|
||||
await novu.subscribers.preferences.update(
|
||||
{
|
||||
channels: {
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
payload.subscriberId,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to update Novu preferences", error);
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error:
|
||||
"We could not update your email preferences right now. Please try again later.",
|
||||
code: "NOVU_ERROR",
|
||||
});
|
||||
}
|
||||
try {
|
||||
await novu.subscribers.preferences.update(
|
||||
{
|
||||
channels: {
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
payload.subscriberId,
|
||||
);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error:
|
||||
"We could not update your email preferences right now. Please try again later.",
|
||||
code: "NOVU_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
},
|
||||
return res.status(200).json({ success: true });
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
@@ -22,7 +23,7 @@ export const config = {
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
@@ -36,7 +37,9 @@ export default withRateLimit(
|
||||
|
||||
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (!bucket) {
|
||||
return res.status(500).json({ error: "Attachments bucket not configured" });
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Attachments bucket not configured" });
|
||||
}
|
||||
|
||||
const cardPublicId = req.query.cardPublicId;
|
||||
@@ -55,7 +58,9 @@ export default withRateLimit(
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
@@ -129,8 +134,7 @@ export default withRateLimit(
|
||||
|
||||
return res.status(200).json({ attachment });
|
||||
} catch (error) {
|
||||
console.error("Attachment upload failed", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,13 +2,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
|
||||
import { env } from "~/env";
|
||||
|
||||
const MAX_SIZE_BYTES = parseInt(
|
||||
process.env.S3_AVATAR_UPLOAD_LIMIT || "2097152",
|
||||
10,
|
||||
); // Default 2MB
|
||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export const config = {
|
||||
@@ -19,7 +23,7 @@ export const config = {
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
@@ -51,7 +55,9 @@ export default withRateLimit(
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
@@ -93,9 +99,7 @@ export default withRateLimit(
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Avatar upload failed", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
31
apps/web/src/pages/onboarding/select-plan.tsx
Normal file
31
apps/web/src/pages/onboarding/select-plan.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import SelectPlanView from "~/views/onboarding/select-plan";
|
||||
|
||||
export default function SelectPlanPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session?.user) {
|
||||
router.push("/login");
|
||||
}
|
||||
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||
router.push("/boards");
|
||||
}
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending || !session?.user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Select plan | kan.bn" />
|
||||
<SelectPlanView />
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/pages/onboarding/workspace.tsx
Normal file
28
apps/web/src/pages/onboarding/workspace.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import WorkspaceDetailsView from "~/views/onboarding/workspace-details";
|
||||
|
||||
export default function WorkspaceDetailsPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session?.user) router.push("/login");
|
||||
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud")
|
||||
router.push("/boards");
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending || !session?.user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Create workspace | kan.bn" />
|
||||
<WorkspaceDetailsView />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ interface Workspace {
|
||||
description: string | null | undefined;
|
||||
publicId: string;
|
||||
slug: string | undefined;
|
||||
plan: "free" | "pro" | "enterprise" | undefined;
|
||||
plan: "free" | "team" | "pro" | "enterprise" | undefined;
|
||||
role: "admin" | "member" | "guest";
|
||||
weekStartDay: 0 | 1 | 6;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const initialWorkspace: Workspace = {
|
||||
description: null,
|
||||
publicId: "",
|
||||
slug: "",
|
||||
plan: "free",
|
||||
plan: "free" as const,
|
||||
role: "member",
|
||||
weekStartDay: 1,
|
||||
};
|
||||
@@ -50,7 +50,15 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const workspacePublicId = useSearchParams().get("workspacePublicId");
|
||||
|
||||
const { data, isLoading } = api.workspace.all.useQuery();
|
||||
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<string | null>(
|
||||
workspacePublicId,
|
||||
);
|
||||
const pollAttemptsRef = React.useRef(0);
|
||||
const MAX_POLL_ATTEMPTS = 5;
|
||||
|
||||
const { data, isLoading } = api.workspace.all.useQuery(undefined, {
|
||||
refetchInterval: pendingWorkspaceId ? 2000 : false,
|
||||
});
|
||||
const utils = api.useUtils();
|
||||
|
||||
const switchWorkspace = (_workspace: Workspace) => {
|
||||
@@ -94,7 +102,16 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
({ workspace }) => workspace.publicId === storedWorkspaceId,
|
||||
);
|
||||
|
||||
if (!selectedWorkspace?.workspace) return;
|
||||
if (!selectedWorkspace?.workspace) {
|
||||
pollAttemptsRef.current += 1;
|
||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||
setPendingWorkspaceId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pollAttemptsRef.current = 0;
|
||||
setPendingWorkspaceId(null);
|
||||
|
||||
setWorkspace({
|
||||
publicId: selectedWorkspace.workspace.publicId,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export const formatToArray = (
|
||||
value: string | string[] | undefined,
|
||||
): string[] => {
|
||||
@@ -50,5 +52,21 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
// Construct URL from S3 key
|
||||
const useVirtualHosted = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS") === "true";
|
||||
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
||||
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
||||
|
||||
if (useVirtualHosted && storageDomain && bucket) {
|
||||
// Virtual-hosted style: https://{bucket}.{domain}/{key}
|
||||
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
||||
}
|
||||
|
||||
if (storageUrl && bucket) {
|
||||
// Path-style: {storageUrl}/{bucket}/{key}
|
||||
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ const loadMessages = async (locale: Locale) => {
|
||||
return (await import("~/locales/ru/messages")).messages;
|
||||
case "pl":
|
||||
return (await import("~/locales/pl/messages")).messages;
|
||||
case "pt-BR":
|
||||
return (await import("~/locales/pt-BR/messages")).messages;
|
||||
case "ptbr":
|
||||
return (await import("~/locales/ptbr/messages")).messages;
|
||||
default:
|
||||
return enMessages;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
@@ -29,6 +30,7 @@ export default function BoardDropdown({
|
||||
isFavorite?: boolean;
|
||||
boardName?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
|
||||
@@ -47,6 +49,7 @@ export default function BoardDropdown({
|
||||
: t`The board has been unarchived.`,
|
||||
icon: "success",
|
||||
});
|
||||
void router.push(`/boards`);
|
||||
} else if (variables.favorite !== undefined) {
|
||||
showPopup({
|
||||
header: variables.favorite
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
import { DueDateSelector } from "~/views/card/components/DueDateSelector";
|
||||
|
||||
export function CardContextDueDateModal() {
|
||||
const { entityId: cardPublicId, closeModal } = useModal();
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardPublicId ?? "" },
|
||||
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||
);
|
||||
|
||||
if (!cardPublicId) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Set due date`}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
|
||||
) : (
|
||||
<DueDateSelector
|
||||
cardPublicId={cardPublicId}
|
||||
dueDate={card?.dueDate ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||
>
|
||||
{t`Done`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
Transition,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useState } from "react";
|
||||
import { HiChevronDown } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface CardContextDuplicateModalProps {
|
||||
boardPublicId?: string;
|
||||
isTemplate?: boolean;
|
||||
}
|
||||
|
||||
export function CardContextDuplicateModal({
|
||||
boardPublicId: boardPublicIdProp,
|
||||
isTemplate: isTemplateProp,
|
||||
}: CardContextDuplicateModalProps = {}) {
|
||||
const { entityId: cardPublicId, closeModal, getModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const modalState = getModalState("CARD_CONTEXT_DUPLICATE") as
|
||||
| { boardPublicId: string; isTemplate?: boolean }
|
||||
| undefined;
|
||||
const boardPublicId =
|
||||
boardPublicIdProp ?? modalState?.boardPublicId ?? "";
|
||||
const isTemplate = isTemplateProp ?? modalState?.isTemplate ?? false;
|
||||
|
||||
const [listPublicId, setListPublicId] = useState("");
|
||||
const [copyLabels, setCopyLabels] = useState(true);
|
||||
const [copyMembers, setCopyMembers] = useState(true);
|
||||
const [copyChecklists, setCopyChecklists] = useState(true);
|
||||
const [position, setPosition] = useState<string>("");
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
const { data: card, isLoading: isCardLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardPublicId ?? "" },
|
||||
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||
);
|
||||
|
||||
const boardType = isTemplate ? "template" : "regular";
|
||||
const { data: board } = api.board.byId.useQuery(
|
||||
{ boardPublicId, type: boardType },
|
||||
{ enabled: !!boardPublicId },
|
||||
);
|
||||
const lists = board?.lists ?? [];
|
||||
const listOptions = lists.map((l) => ({ publicId: l.publicId, name: l.name }));
|
||||
const currentListPublicId = card?.list?.publicId;
|
||||
const hasLabels = (card?.labels?.length ?? 0) > 0;
|
||||
const hasMembers = (card?.members?.length ?? 0) > 0;
|
||||
const hasChecklists = (card?.checklists?.length ?? 0) > 0;
|
||||
const hasAnyCopyOption = hasLabels || hasMembers || hasChecklists;
|
||||
|
||||
const duplicateCard = api.card.duplicate.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Card duplicated`,
|
||||
icon: "success",
|
||||
message: t`The card has been duplicated.`,
|
||||
});
|
||||
closeModal();
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to duplicate card`,
|
||||
message: t`Please try again.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!cardPublicId || !listPublicId) return;
|
||||
const indexNum =
|
||||
position === "" ? undefined : parseInt(position, 10);
|
||||
if (
|
||||
position !== "" &&
|
||||
(indexNum === undefined || isNaN(indexNum) || indexNum < 0)
|
||||
)
|
||||
return;
|
||||
duplicateCard.mutate({
|
||||
cardPublicId,
|
||||
listPublicId,
|
||||
copyLabels,
|
||||
copyMembers,
|
||||
copyChecklists,
|
||||
...(typeof indexNum === "number" && { index: indexNum }),
|
||||
title: title.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (!cardPublicId) return null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Duplicate card`}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`List`}
|
||||
</label>
|
||||
<Listbox value={listPublicId} onChange={setListPublicId}>
|
||||
<div className="relative">
|
||||
<ListboxButton
|
||||
className={twMerge(
|
||||
"relative w-full cursor-pointer rounded-md border border-light-300 bg-white py-2 pl-3 pr-10 text-left text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-light-400 focus:ring-offset-0 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:focus:ring-dark-500",
|
||||
!listPublicId && "text-light-600 dark:text-dark-600",
|
||||
)}
|
||||
>
|
||||
<span className="block truncate">
|
||||
{listPublicId
|
||||
? listOptions.find((o) => o.publicId === listPublicId)
|
||||
?.name
|
||||
: t`Select a list`}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
|
||||
<HiChevronDown
|
||||
className="h-4 w-4 text-light-600 dark:text-dark-600"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions className="absolute z-50 mt-1 w-full overflow-hidden rounded-md border border-light-200 bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-200">
|
||||
<div className="max-h-60 overflow-y-auto py-1 pr-1 scrollbar scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
||||
{listOptions.map((option) => {
|
||||
const isCurrentList =
|
||||
option.publicId === currentListPublicId;
|
||||
return (
|
||||
<ListboxOption
|
||||
key={option.publicId}
|
||||
value={option.publicId}
|
||||
disabled={isCurrentList}
|
||||
className={({ focus }) =>
|
||||
twMerge(
|
||||
"relative select-none py-2 pl-3 pr-9 text-sm",
|
||||
isCurrentList
|
||||
? "cursor-default opacity-50"
|
||||
: "cursor-pointer",
|
||||
!isCurrentList && focus
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-400 dark:text-dark-1000"
|
||||
: "text-light-900 dark:text-dark-900",
|
||||
)
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</ListboxOption>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Title (optional)`}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={card?.title ?? ""}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Position in list (0 = first, leave empty for end)`}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value)}
|
||||
placeholder={t`End of list`}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasAnyCopyOption && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasLabels && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyLabels}
|
||||
onChange={(e) => setCopyLabels(e.target.checked)}
|
||||
className={twMerge(
|
||||
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Copy labels`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasMembers && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyMembers}
|
||||
onChange={(e) => setCopyMembers(e.target.checked)}
|
||||
className={twMerge(
|
||||
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Copy members`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasChecklists && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyChecklists}
|
||||
onChange={(e) => setCopyChecklists(e.target.checked)}
|
||||
className={twMerge(
|
||||
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Copy checklists`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={closeModal}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={
|
||||
!listPublicId ||
|
||||
duplicateCard.isPending ||
|
||||
isCardLoading ||
|
||||
lists.length === 0
|
||||
}
|
||||
>
|
||||
{duplicateCard.isPending ? t`Duplicating…` : t`Duplicate`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import LabelSelector from "~/views/card/components/LabelSelector";
|
||||
|
||||
export function CardContextLabelsModal() {
|
||||
const { entityId: cardPublicId, closeModal } = useModal();
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardPublicId ?? "" },
|
||||
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||
);
|
||||
|
||||
const boardLabels = card?.list?.board?.labels ?? [];
|
||||
const selectedLabels = card?.labels ?? [];
|
||||
|
||||
const formattedLabels = boardLabels.map((label) => ({
|
||||
key: label.publicId,
|
||||
value: label.name,
|
||||
selected: selectedLabels.some((l) => l.publicId === label.publicId),
|
||||
leftIcon: <LabelIcon colourCode={label.colourCode} />,
|
||||
}));
|
||||
|
||||
if (!cardPublicId) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Labels`}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
|
||||
) : (
|
||||
<LabelSelector
|
||||
cardPublicId={cardPublicId}
|
||||
labels={formattedLabels}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||
>
|
||||
{t`Done`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import MemberSelector from "~/views/card/components/MemberSelector";
|
||||
|
||||
export function CardContextMembersModal() {
|
||||
const { entityId: cardPublicId, closeModal } = useModal();
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardPublicId ?? "" },
|
||||
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||
);
|
||||
|
||||
const board = card?.list?.board;
|
||||
const workspaceMembers = board?.workspace?.members ?? [];
|
||||
const selectedMembers = card?.members ?? [];
|
||||
|
||||
const formattedMembers = workspaceMembers.map((member) => {
|
||||
const isSelected = selectedMembers.some(
|
||||
(m) => m.publicId === member.publicId,
|
||||
);
|
||||
return {
|
||||
key: member.publicId,
|
||||
value: formatMemberDisplayName(
|
||||
member.user?.name ?? null,
|
||||
member.user?.email ?? member.email,
|
||||
),
|
||||
imageUrl: member.user?.image ? getAvatarUrl(member.user.image) : undefined,
|
||||
selected: isSelected,
|
||||
leftIcon: (
|
||||
<Avatar
|
||||
size="xs"
|
||||
name={member.user?.name ?? ""}
|
||||
imageUrl={
|
||||
member.user?.image ? getAvatarUrl(member.user.image) : undefined
|
||||
}
|
||||
email={member.user?.email ?? member.email}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
if (!cardPublicId) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Manage members`}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
|
||||
) : (
|
||||
<MemberSelector
|
||||
cardPublicId={cardPublicId}
|
||||
members={formattedMembers}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||
>
|
||||
{t`Done`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/web/src/views/board/components/CardContextMenu.tsx
Normal file
130
apps/web/src/views/board/components/CardContextMenu.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
HiLink,
|
||||
HiOutlineCalendar,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineTag,
|
||||
HiOutlineTrash,
|
||||
HiOutlineUserGroup,
|
||||
HiOutlineArrowRightCircle,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
export type CardContextMenuAction =
|
||||
| "members"
|
||||
| "move"
|
||||
| "labels"
|
||||
| "dueDate"
|
||||
| "copyLink"
|
||||
| "duplicate"
|
||||
| "delete";
|
||||
|
||||
interface CardContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
onAction: (action: CardContextMenuAction) => void;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
const MENU_ITEMS: {
|
||||
action: CardContextMenuAction;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
requiresEdit: boolean;
|
||||
}[] = [
|
||||
{
|
||||
action: "members",
|
||||
label: t`Manage members`,
|
||||
icon: <HiOutlineUserGroup className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
{
|
||||
action: "move",
|
||||
label: t`Move to another list`,
|
||||
icon: <HiOutlineArrowRightCircle className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
{
|
||||
action: "labels",
|
||||
label: t`Add / edit label`,
|
||||
icon: <HiOutlineTag className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
{
|
||||
action: "dueDate",
|
||||
label: t`Set due date`,
|
||||
icon: <HiOutlineCalendar className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
{
|
||||
action: "copyLink",
|
||||
label: t`Copy link to card`,
|
||||
icon: <HiLink className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: false,
|
||||
},
|
||||
{
|
||||
action: "duplicate",
|
||||
label: t`Duplicate card`,
|
||||
icon: <HiOutlineDocumentDuplicate className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
{
|
||||
action: "delete",
|
||||
label: t`Delete card`,
|
||||
icon: <HiOutlineTrash className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function CardContextMenu({
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
onAction,
|
||||
canEdit,
|
||||
}: CardContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const items = MENU_ITEMS.filter((item) => !item.requiresEdit || canEdit);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[200] min-w-[200px] rounded-md border border-light-200 bg-white py-1 shadow-lg dark:border-dark-400 dark:bg-dark-200"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{items.map(({ action, label, icon }) => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAction(action);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/views/board/components/CardContextMoveListModal.tsx
Normal file
101
apps/web/src/views/board/components/CardContextMoveListModal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
export function CardContextMoveListModal() {
|
||||
const { entityId: cardPublicId, closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardPublicId ?? "" },
|
||||
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||
);
|
||||
|
||||
const updateCardList = api.card.update.useMutation({
|
||||
onMutate: async (vars) => {
|
||||
if (!cardPublicId) return undefined;
|
||||
await utils.card.byId.cancel();
|
||||
const previous = utils.card.byId.getData({ cardPublicId });
|
||||
utils.card.byId.setData({ cardPublicId }, (old) => {
|
||||
if (!old) return old;
|
||||
const list = old.list.board?.lists?.find(
|
||||
(l) => l.publicId === vars.listPublicId,
|
||||
);
|
||||
if (!list) return old;
|
||||
return {
|
||||
...old,
|
||||
list: { ...old.list, publicId: list.publicId, name: list.name },
|
||||
};
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (cardPublicId && ctx?.previous) {
|
||||
utils.card.byId.setData({ cardPublicId }, ctx.previous);
|
||||
}
|
||||
showPopup({
|
||||
header: t`Unable to move card`,
|
||||
message: t`Please try again.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
if (cardPublicId) {
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
await utils.board.byId.invalidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const lists = card?.list?.board?.lists ?? [];
|
||||
const currentListPublicId = card?.list?.publicId;
|
||||
|
||||
const handleSelectList = (listPublicId: string) => {
|
||||
if (listPublicId === currentListPublicId || !cardPublicId) return;
|
||||
updateCardList.mutate(
|
||||
{ cardPublicId, listPublicId, index: 0 },
|
||||
{ onSuccess: closeModal },
|
||||
);
|
||||
};
|
||||
|
||||
if (!cardPublicId) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Move to list`}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[60vh] overflow-y-auto pr-1 scrollbar scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
||||
<ul className="space-y-1">
|
||||
{lists.map((list) => (
|
||||
<li key={list.publicId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectList(list.publicId)}
|
||||
disabled={list.publicId === currentListPublicId}
|
||||
className="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-light-200 disabled:opacity-50 dark:hover:bg-dark-400"
|
||||
>
|
||||
{list.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +158,9 @@ export function NewCardForm({
|
||||
...member,
|
||||
deletedAt: null,
|
||||
})) ?? [],
|
||||
comments: [],
|
||||
checklists: [],
|
||||
attachments: [],
|
||||
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
|
||||
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
|
||||
index: position === "start" ? 0 : list.cards.length,
|
||||
@@ -515,7 +518,7 @@ export function NewCardForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="mt-5 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Create another`}
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function NewListForm({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Create another`}
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
|
||||
import type { UpdateBoardInput } from "@kan/api/types";
|
||||
|
||||
import type { CardContextMenuAction } from "./components/CardContextMenu";
|
||||
import Button from "~/components/Button";
|
||||
import { DeleteLabelConfirmation } from "~/components/DeleteLabelConfirmation";
|
||||
import { LabelForm } from "~/components/LabelForm";
|
||||
@@ -26,16 +28,23 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { useScrollRestore } from "~/hooks/useScrollRestore";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useScrollRestore } from "~/hooks/useScrollRestore";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { formatToArray } from "~/utils/helpers";
|
||||
import { DeleteCardConfirmation } from "~/views/card/components/DeleteCardConfirmation";
|
||||
import BoardDropdown from "./components/BoardDropdown";
|
||||
import Card from "./components/Card";
|
||||
import { CardContextDueDateModal } from "./components/CardContextDueDateModal";
|
||||
import { CardContextDuplicateModal } from "./components/CardContextDuplicateModal";
|
||||
import { CardContextLabelsModal } from "./components/CardContextLabelsModal";
|
||||
import { CardContextMembersModal } from "./components/CardContextMembersModal";
|
||||
import { CardContextMenu } from "./components/CardContextMenu";
|
||||
import { CardContextMoveListModal } from "./components/CardContextMoveListModal";
|
||||
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
|
||||
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
|
||||
import Filters from "./components/Filters";
|
||||
@@ -55,11 +64,18 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal, modalContentType, entityId, isOpen } = useModal();
|
||||
const { openModal, modalContentType, entityId, isOpen, setModalState } =
|
||||
useModal();
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
cardPublicId: string;
|
||||
} | null>(null);
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
@@ -134,7 +150,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
// Redirect to 404 if board doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && boardId && !isQueryLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!boardData && !isQueryLoading)) {
|
||||
if (
|
||||
error?.data?.code === "NOT_FOUND" ||
|
||||
(!boardData && !isQueryLoading)
|
||||
) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
@@ -152,7 +171,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
const isLoading = isInitialLoading || isQueryLoading;
|
||||
|
||||
useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0);
|
||||
useScrollRestore(
|
||||
boardId,
|
||||
scrollRef,
|
||||
router,
|
||||
!isLoading && (boardData?.lists.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
const updateListMutation = api.list.update.useMutation({
|
||||
onMutate: async (args) => {
|
||||
@@ -267,6 +291,56 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
setSelectedPublicListId(publicBoardId);
|
||||
};
|
||||
|
||||
const handleCardContextMenuAction = (action: CardContextMenuAction) => {
|
||||
const cardPublicId = contextMenu?.cardPublicId;
|
||||
if (!cardPublicId) return;
|
||||
setContextMenu(null);
|
||||
if (action === "copyLink") {
|
||||
const path = isTemplate
|
||||
? `/templates/${boardId}/cards/${cardPublicId}`
|
||||
: `/cards/${cardPublicId}`;
|
||||
const url = `${typeof window !== "undefined" ? window.location.origin : ""}${path}`;
|
||||
void navigator.clipboard.writeText(url).then(
|
||||
() => {
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Card URL copied to clipboard`,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
showPopup({
|
||||
header: t`Unable to copy link`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === "duplicate") {
|
||||
setModalState("CARD_CONTEXT_DUPLICATE", {
|
||||
boardPublicId: boardId ?? "",
|
||||
isTemplate: !!isTemplate,
|
||||
});
|
||||
openModal("CARD_CONTEXT_DUPLICATE", cardPublicId);
|
||||
return;
|
||||
}
|
||||
if (action === "delete") {
|
||||
openModal("DELETE_CARD", cardPublicId);
|
||||
return;
|
||||
}
|
||||
const modalType =
|
||||
action === "members"
|
||||
? "CARD_CONTEXT_MEMBERS"
|
||||
: action === "move"
|
||||
? "CARD_CONTEXT_MOVE_LIST"
|
||||
: action === "labels"
|
||||
? "CARD_CONTEXT_LABELS"
|
||||
: "CARD_CONTEXT_DUE_DATE";
|
||||
openModal(modalType, cardPublicId);
|
||||
};
|
||||
|
||||
const onDragEnd = ({
|
||||
source: _source,
|
||||
destination,
|
||||
@@ -403,6 +477,49 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
<EditYouTubeModal />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CARD_CONTEXT_MEMBERS"}
|
||||
>
|
||||
<CardContextMembersModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CARD_CONTEXT_MOVE_LIST"}
|
||||
>
|
||||
<CardContextMoveListModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CARD_CONTEXT_LABELS"}
|
||||
>
|
||||
<CardContextLabelsModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CARD_CONTEXT_DUE_DATE"}
|
||||
>
|
||||
<CardContextDueDateModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "CARD_CONTEXT_DUPLICATE"}
|
||||
>
|
||||
<CardContextDuplicateModal
|
||||
boardPublicId={boardId ?? ""}
|
||||
isTemplate={!!isTemplate}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_CARD"}
|
||||
>
|
||||
<DeleteCardConfirmation
|
||||
cardPublicId={entityId}
|
||||
boardPublicId={boardId ?? ""}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -605,18 +722,35 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
)
|
||||
e.preventDefault();
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
if (
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
) ||
|
||||
env("NEXT_PUBLIC_KAN_ENV") ===
|
||||
"cloud"
|
||||
)
|
||||
return;
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
cardPublicId: card.publicId,
|
||||
});
|
||||
}}
|
||||
key={card.publicId}
|
||||
href={
|
||||
isTemplate
|
||||
? `/templates/${boardId}/cards/${card.publicId}`
|
||||
: `/cards/${card.publicId}`
|
||||
}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
@@ -653,6 +787,15 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<CardContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onAction={handleCardContextMenuAction}
|
||||
canEdit={!!canEditCard}
|
||||
/>
|
||||
)}
|
||||
{renderModalContent()}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -184,7 +184,7 @@ const SelectSource = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -310,7 +310,7 @@ const ImportGithub: React.FC = () => {
|
||||
<form onSubmit={handleSubmitProjects(onSubmitProjects)}>
|
||||
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Select all`}
|
||||
isChecked={!!isSelectAllEnabled}
|
||||
@@ -459,7 +459,7 @@ const ImportTrello: React.FC = () => {
|
||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Select all`}
|
||||
isChecked={!!isSelectAllEnabled}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
|
||||
showTemplates={showTemplates}
|
||||
customTemplates={formattedTemplates ?? []}
|
||||
/>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isTemplate && (
|
||||
<Toggle
|
||||
label={t`Use template`}
|
||||
|
||||
@@ -525,7 +525,6 @@ const ActivityList = ({
|
||||
comment={activity.comment?.comment}
|
||||
isEdited={!!activity.comment?.updatedAt}
|
||||
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
|
||||
isAdmin={isAdmin ?? false}
|
||||
isViewOnly={!!isViewOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -78,6 +78,7 @@ export default function LabelSelector({
|
||||
},
|
||||
onSettled: async () => {
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function ListSelector({
|
||||
},
|
||||
onSettled: async () => {
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function MemberSelector({
|
||||
},
|
||||
onSettled: async () => {
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ export function InviteMemberForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={
|
||||
isShareInviteLinkEnabled
|
||||
|
||||
289
apps/web/src/views/onboarding/select-plan/index.tsx
Normal file
289
apps/web/src/views/onboarding/select-plan/index.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { HiUser } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type PlanId = "solo" | "team" | "pro";
|
||||
type Billing = "monthly" | "annual";
|
||||
|
||||
// Each user orbits via a zero-size pivot div at center (144,144) that rotates.
|
||||
// The icon is offset from the pivot by `radius` pixels along the x-axis.
|
||||
// A counter-rotation on the icon keeps it upright.
|
||||
const ORBIT_USERS: Record<
|
||||
PlanId,
|
||||
{ id: string; angle: number; radius: number; duration: number }[]
|
||||
> = {
|
||||
solo: [],
|
||||
team: [
|
||||
{ id: "t1", angle: 45, radius: 104, duration: 50 },
|
||||
{ id: "t2", angle: 170, radius: 104, duration: 50 },
|
||||
{ id: "t3", angle: 290, radius: 104, duration: 50 },
|
||||
],
|
||||
pro: [
|
||||
{ id: "t1", angle: 45, radius: 104, duration: 50 },
|
||||
{ id: "t2", angle: 170, radius: 104, duration: 50 },
|
||||
{ id: "t3", angle: 290, radius: 104, duration: 50 },
|
||||
{ id: "p1", angle: 10, radius: 144, duration: 70 },
|
||||
{ id: "p2", angle: 120, radius: 144, duration: 70 },
|
||||
{ id: "p4", angle: 240, radius: 144, duration: 70 },
|
||||
{ id: "p3", angle: 230, radius: 64, duration: 30 },
|
||||
],
|
||||
};
|
||||
|
||||
export default function SelectPlanView() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [selected, setSelected] = useState<PlanId>(
|
||||
(searchParams.get("plan") as PlanId | null) ?? "solo",
|
||||
);
|
||||
const [billing, setBilling] = useState<Billing>(
|
||||
(searchParams.get("billing") as Billing | null) ?? "annual",
|
||||
);
|
||||
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
||||
const { data: workspaces } = api.workspace.all.useQuery();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: user } = api.user.getUser.useQuery(undefined, {
|
||||
enabled: !!session?.user,
|
||||
});
|
||||
|
||||
const userImage = user?.image ?? session?.user.image ?? null;
|
||||
|
||||
const hasExistingWorkspace = !!workspaces?.length;
|
||||
|
||||
const FREQUENCIES: { value: Billing; label: string }[] = [
|
||||
{ value: "monthly", label: t`Monthly` },
|
||||
{ value: "annual", label: t`Annual` },
|
||||
];
|
||||
|
||||
const PLANS: {
|
||||
id: PlanId;
|
||||
name: string;
|
||||
monthly: string;
|
||||
annual: string;
|
||||
description: string;
|
||||
trial?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
id: "solo",
|
||||
name: t`Solo`,
|
||||
monthly: t`Free`,
|
||||
annual: t`Free`,
|
||||
description: t`Good for individuals starting out who just need the essentials.`,
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
name: t`Team`,
|
||||
monthly: "$10/user/mo",
|
||||
annual: "$8/user/mo",
|
||||
description: t`Best for small teams who want to collaborate and move faster together.`,
|
||||
trial: true,
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: t`Pro`,
|
||||
monthly: "$29/mo",
|
||||
annual: "$23/mo",
|
||||
description: t`Unlimited members and a custom workspace username for teams ready to scale.`,
|
||||
trial: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelectPlan = (plan: PlanId) => {
|
||||
setSelected(plan);
|
||||
router.replace(`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
};
|
||||
|
||||
const handleSetBilling = (b: Billing) => {
|
||||
setBilling(b);
|
||||
router.replace(`/onboarding/select-plan?plan=${selected}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
};
|
||||
|
||||
const handleContinue = () =>
|
||||
router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
|
||||
|
||||
const handleCancel = () => router.push(returnUrl);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
|
||||
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
|
||||
<div className="flex flex-col md:h-[520px] md:flex-row">
|
||||
{/* Left panel */}
|
||||
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
|
||||
{t`Choose a plan`}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
|
||||
{t`Pick a plan to get started. All paid plans include a 14-day free trial.`}
|
||||
</p>
|
||||
|
||||
{/* Billing toggle */}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<RadioGroup
|
||||
value={billing}
|
||||
onChange={handleSetBilling}
|
||||
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
|
||||
>
|
||||
{FREQUENCIES.map((f) => (
|
||||
<Radio
|
||||
key={f.value}
|
||||
value={f.value}
|
||||
className={twMerge(
|
||||
"cursor-pointer rounded-full px-2.5 py-0.5 text-xs transition-colors",
|
||||
billing === f.value
|
||||
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
|
||||
: "text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{PLANS.map((plan) => {
|
||||
const badge =
|
||||
billing === "annual" ? plan.annual : plan.monthly;
|
||||
return (
|
||||
<button
|
||||
key={plan.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPlan(plan.id)}
|
||||
className={`relative w-full rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
selected === plan.id
|
||||
? "border-light-700 bg-light-300 dark:border-dark-600 dark:bg-dark-200"
|
||||
: "border-light-500 bg-light-200 hover:border-light-600 dark:border-dark-500 dark:bg-dark-100 dark:hover:border-dark-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{plan.name}
|
||||
</span>
|
||||
<span className="rounded-full bg-neutral-700 px-2 py-px text-[11px] font-medium text-neutral-200">
|
||||
{badge}
|
||||
</span>
|
||||
{plan.trial && billing === "annual" && (
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-px text-[11px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400">
|
||||
-20%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-light-800 dark:text-dark-800">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
|
||||
selected === plan.id
|
||||
? "border-light-900 bg-light-900 dark:border-dark-900 dark:bg-dark-900"
|
||||
: "border-light-700 dark:border-dark-700"
|
||||
}`}
|
||||
>
|
||||
{selected === plan.id && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-light-100 dark:bg-dark-100" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2 md:mt-8">
|
||||
{hasExistingWorkspace && (
|
||||
<Button variant="ghost" onClick={handleCancel}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleContinue}>{t`Continue`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="hidden items-center justify-center bg-light-200 dark:bg-dark-200 md:flex md:w-[45%]">
|
||||
<div className="relative h-72 w-72">
|
||||
{/* Orbit rings */}
|
||||
<div className="absolute inset-0 m-auto h-72 w-72 rounded-full border border-light-400 dark:border-dark-400" />
|
||||
<div className="absolute inset-0 m-auto h-52 w-52 rounded-full border border-light-400 dark:border-dark-400" />
|
||||
<div className="absolute inset-0 m-auto h-32 w-32 rounded-full border border-light-400 dark:border-dark-400" />
|
||||
|
||||
{/* Centre person */}
|
||||
<div className="absolute inset-0 m-auto flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-light-300 dark:bg-dark-300">
|
||||
{userImage ? (
|
||||
<img
|
||||
src={userImage}
|
||||
alt={t`Your avatar`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<HiUser className="h-7 w-7 text-light-700 dark:text-dark-700" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Orbiting users — zero-size pivot at center rotates; icon offset by radius stays on the ring */}
|
||||
<AnimatePresence>
|
||||
{ORBIT_USERS[selected].map((u) => (
|
||||
<motion.div
|
||||
key={u.id}
|
||||
style={{ position: "absolute", left: 144, top: 144 }}
|
||||
initial={{ opacity: 0, rotate: u.angle }}
|
||||
animate={{ opacity: 1, rotate: u.angle + 360 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
opacity: { duration: 0.3 },
|
||||
rotate: {
|
||||
duration: u.duration,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
repeatType: "loop",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Counter-rotate icon so it stays upright */}
|
||||
<motion.div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: u.radius - 12,
|
||||
top: -12,
|
||||
}}
|
||||
initial={{ rotate: -u.angle }}
|
||||
animate={{ rotate: -(u.angle + 360) }}
|
||||
transition={{
|
||||
duration: u.duration,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
repeatType: "loop",
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full bg-light-400 dark:bg-dark-400"
|
||||
>
|
||||
<HiUser className="h-3.5 w-3.5 text-light-800 dark:text-dark-800" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!hasExistingWorkspace && (
|
||||
<Button variant="ghost" onClick={() => authClient.signOut()}>
|
||||
{t`Sign out`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
apps/web/src/views/onboarding/workspace-details/index.tsx
Normal file
368
apps/web/src/views/onboarding/workspace-details/index.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
HiArrowLeft,
|
||||
HiArrowPath,
|
||||
HiArrowRight,
|
||||
HiCheck,
|
||||
HiEllipsisVertical,
|
||||
HiInformationCircle,
|
||||
HiLockClosed,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
export default function WorkspaceNameView() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const plan = searchParams.get("plan") ?? "solo";
|
||||
const billing = searchParams.get("billing") ?? "annual";
|
||||
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
||||
const { showPopup } = usePopup();
|
||||
const [isProToggle, setIsProToggle] = useState(plan === "pro");
|
||||
const effectivePlan = isProToggle ? "pro" : plan;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (isProToggle && !slugManuallyEdited) {
|
||||
setSlug(slugify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
const isTyping = slug !== debouncedSlug;
|
||||
|
||||
const slugAvailability = api.workspace.checkSlugAvailability.useQuery(
|
||||
{ workspaceSlug: debouncedSlug },
|
||||
{ enabled: isProToggle && debouncedSlug.length >= 3 && !isTyping },
|
||||
);
|
||||
|
||||
const isSlugAvailable =
|
||||
slugAvailability.data?.isAvailable && !slugAvailability.data?.isReserved;
|
||||
const isSlugTaken =
|
||||
slugAvailability.data?.isAvailable === false &&
|
||||
!slugAvailability.data?.isReserved;
|
||||
const isSlugReserved = slugAvailability.data?.isReserved === true;
|
||||
|
||||
const slugError = isSlugTaken
|
||||
? t`This URL has already been taken`
|
||||
: isSlugReserved
|
||||
? t`This URL is reserved`
|
||||
: undefined;
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: user } = api.user.getUser.useQuery(undefined, {
|
||||
enabled: !!session?.user,
|
||||
});
|
||||
const { data: workspaces } = api.workspace.all.useQuery();
|
||||
const hasExistingWorkspace = !!workspaces?.length;
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const previewSlug = isProToggle
|
||||
? slugify(slug) || slugify(name) || "your-workspace"
|
||||
: "your-workspace";
|
||||
|
||||
const createWorkspace = api.workspace.create.useMutation({
|
||||
onSuccess: async (workspace) => {
|
||||
if (!workspace.publicId) return;
|
||||
localStorage.setItem("workspacePublicId", workspace.publicId);
|
||||
void utils.workspace.all.invalidate();
|
||||
router.push("/boards");
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to create workspace`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [isRedirectingToCheckout, setIsRedirectingToCheckout] = useState(false);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
if (effectivePlan === "solo") {
|
||||
createWorkspace.mutate({
|
||||
name: name.trim(),
|
||||
...(description.trim() && { description: description.trim() }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// team/pro: redirect to Stripe — workspace created on checkout_success
|
||||
setIsRedirectingToCheckout(true);
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_checkout_session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceName: name.trim(),
|
||||
...(description.trim() && {
|
||||
workspaceDescription: description.trim(),
|
||||
}),
|
||||
...(effectivePlan === "pro" && slug ? { workspaceSlug: slug } : {}),
|
||||
cancelUrl: `${window.location.pathname}?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
successUrl: "/boards",
|
||||
billing,
|
||||
plan: effectivePlan,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
const url = (data as { url: string }).url;
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
setIsRedirectingToCheckout(false);
|
||||
showPopup({
|
||||
header: t`Unable to start checkout`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementById("workspace-name-input")?.focus();
|
||||
}, []);
|
||||
|
||||
const displayName = user?.name ?? session?.user.name ?? "";
|
||||
|
||||
const BOARDS = [t`Roadmap`, t`Engineering`, t`Marketing`];
|
||||
const [visibleCount, setVisibleCount] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = BOARDS.slice(1).map((_, i) =>
|
||||
setTimeout(() => setVisibleCount(i + 2), (i + 1) * 2000),
|
||||
);
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
|
||||
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
|
||||
<div className="flex flex-col md:h-[520px] md:flex-row">
|
||||
{/* Left panel */}
|
||||
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
|
||||
{t`Set up your workspace`}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
|
||||
{t`You can always change these later in settings.`}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<Input
|
||||
id="workspace-name-input"
|
||||
placeholder={t`Workspace name`}
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleContinue()}
|
||||
maxLength={64}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={t`your-workspace`}
|
||||
value={isProToggle ? slug : t`your-workspace`}
|
||||
onChange={(e) => {
|
||||
setSlugManuallyEdited(true);
|
||||
setSlug(
|
||||
e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 60),
|
||||
);
|
||||
}}
|
||||
disabled={!isProToggle}
|
||||
prefix="kan.bn/"
|
||||
className={
|
||||
!isProToggle ? "cursor-not-allowed opacity-50" : ""
|
||||
}
|
||||
errorMessage={slugError}
|
||||
iconRight={
|
||||
!isProToggle ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
|
||||
}
|
||||
placement="top"
|
||||
delay={0}
|
||||
>
|
||||
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
|
||||
</Tooltip>
|
||||
) : isProToggle && slug.length >= 3 ? (
|
||||
isTyping || slugAvailability.isPending ? (
|
||||
<LoadingSpinner />
|
||||
) : isSlugAvailable ? (
|
||||
<HiCheck className="h-4 w-4 text-white" />
|
||||
) : null
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{plan !== "pro" && (
|
||||
<div className="pb-2">
|
||||
<Toggle
|
||||
isChecked={isProToggle}
|
||||
onChange={() => setIsProToggle((v) => !v)}
|
||||
label={t`Upgrade to Pro ($29/month)`}
|
||||
labelPosition="after"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t`Workspace description`}
|
||||
maxLength={280}
|
||||
rows={3}
|
||||
className="block w-full resize-none rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
|
||||
/>
|
||||
<p className="mt-1 text-right text-[10px] text-light-700 dark:text-dark-700">
|
||||
{description.length}/280
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.replace(
|
||||
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Back`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleContinue()}
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
createWorkspace.isPending ||
|
||||
isRedirectingToCheckout ||
|
||||
(isProToggle &&
|
||||
slug.length >= 3 &&
|
||||
(isTyping || slugAvailability.isPending || !!slugError))
|
||||
}
|
||||
isLoading={
|
||||
createWorkspace.isPending || isRedirectingToCheckout
|
||||
}
|
||||
>
|
||||
{t`Continue`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — browser mockup */}
|
||||
<div className="hidden flex-col bg-light-300 dark:bg-dark-200 md:flex md:w-[45%]">
|
||||
{/* Browser chrome */}
|
||||
<div className="flex items-center gap-2 border-b border-light-400 px-3 py-2.5 dark:border-dark-400">
|
||||
<div className="flex items-center gap-1.5 text-light-700 dark:text-dark-700">
|
||||
<HiArrowLeft className="h-3.5 w-3.5" />
|
||||
<HiArrowRight className="h-3.5 w-3.5" />
|
||||
<HiArrowPath className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center gap-1.5 rounded-md bg-light-200 px-2.5 py-1 dark:bg-dark-300">
|
||||
<HiLockClosed className="h-3 w-3 flex-shrink-0 text-light-700 dark:text-dark-700" />
|
||||
<span className="truncate text-xs text-light-900 dark:text-dark-900">
|
||||
kan.bn/
|
||||
<span className="text-light-1000 dark:text-dark-1000">
|
||||
{previewSlug}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<HiEllipsisVertical className="h-4 w-4 flex-shrink-0 text-light-700 dark:text-dark-700" />
|
||||
</div>
|
||||
|
||||
{/* Browser content */}
|
||||
<div className="flex flex-1 flex-col items-center overflow-hidden px-14 py-8">
|
||||
{/* Workspace name + description */}
|
||||
<p className="text-center text-xs font-bold text-light-1000 dark:text-dark-1000">
|
||||
{name || t`Your workspace`}
|
||||
</p>
|
||||
<p className="mt-0.5 line-clamp-2 break-all text-center text-[10px] text-light-800 dark:text-dark-800">
|
||||
{description || t`Your workspace description`}
|
||||
</p>
|
||||
|
||||
{/* Boards area */}
|
||||
<div className="mt-3 flex w-full flex-1 flex-col rounded-lg bg-light-200 p-2 dark:bg-dark-300">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
{BOARDS.map((board, i) => (
|
||||
<motion.div
|
||||
key={board}
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
animate={
|
||||
i < visibleCount
|
||||
? { opacity: 1, y: 0 }
|
||||
: { opacity: 0, y: -12 }
|
||||
}
|
||||
transition={{
|
||||
opacity: { duration: 0.2 },
|
||||
y: { duration: 0.25, ease: "easeOut" },
|
||||
}}
|
||||
className="flex w-full flex-1 items-center justify-center rounded border border-dashed border-light-400 bg-light-50 px-3 text-[10px] font-medium text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900"
|
||||
>
|
||||
{board}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-2 text-[10px] font-semibold text-light-900 dark:text-dark-900">
|
||||
kan.bn
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!hasExistingWorkspace && (
|
||||
<Button variant="ghost" onClick={() => authClient.signOut()}>
|
||||
{t`Sign out`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,11 +17,11 @@ import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
|
||||
export default function WorkspaceSettings() {
|
||||
|
||||
@@ -110,18 +110,17 @@ export default function Avatar({
|
||||
const cropYpx = (crop.y / 100) * image.naturalHeight;
|
||||
const cropWpx = (crop.width / 100) * image.naturalWidth;
|
||||
const cropHpx = (crop.height / 100) * image.naturalHeight;
|
||||
canvas.width = Math.max(1, Math.floor(cropWpx));
|
||||
canvas.height = Math.max(1, Math.floor(cropHpx));
|
||||
|
||||
// Cap output at 512x512 — avatars display at 64x64, so higher res is wasteful
|
||||
// and causes "File too large" errors on HiDPI screens
|
||||
const maxSize = 512;
|
||||
const scale = Math.min(maxSize / cropWpx, maxSize / cropHpx, 1);
|
||||
canvas.width = Math.max(1, Math.floor(cropWpx * scale));
|
||||
canvas.height = Math.max(1, Math.floor(cropHpx * scale));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas not supported");
|
||||
|
||||
// For better quality on HiDPI screens
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.width * pixelRatio;
|
||||
canvas.height = canvas.height * pixelRatio;
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
cropXpx,
|
||||
@@ -130,8 +129,8 @@ export default function Avatar({
|
||||
cropHpx,
|
||||
0,
|
||||
0,
|
||||
canvas.width / pixelRatio,
|
||||
canvas.height / pixelRatio,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
|
||||
const mime = selectedFile?.type ?? "image/jpeg";
|
||||
@@ -139,6 +138,7 @@ export default function Avatar({
|
||||
canvas.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error("toBlob failed"))),
|
||||
mime,
|
||||
0.85,
|
||||
);
|
||||
});
|
||||
return blob;
|
||||
|
||||
@@ -13,20 +13,29 @@ interface DeleteWebhookConfirmationProps {
|
||||
export function DeleteWebhookConfirmation({
|
||||
workspacePublicId,
|
||||
}: DeleteWebhookConfirmationProps) {
|
||||
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
|
||||
const {
|
||||
closeModal,
|
||||
entityId: webhookPublicId,
|
||||
entityLabel: webhookName,
|
||||
} = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteWebhookMutation = api.webhook.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
|
||||
showPopup({
|
||||
header: t`Webhook deleted`,
|
||||
message: t`Webhook deleted successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to delete webhook`,
|
||||
message: error.message || t`Failed to delete webhook`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -35,7 +44,7 @@ export function DeleteWebhookConfirmation({
|
||||
if (!webhookPublicId) return;
|
||||
deleteWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
webhookPublicId: webhookPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -44,7 +44,12 @@ export function NewWebhookModal({
|
||||
workspacePublicId,
|
||||
isEdit = false,
|
||||
}: NewWebhookModalProps) {
|
||||
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
|
||||
const {
|
||||
closeModal,
|
||||
entityId: webhookPublicId,
|
||||
getModalState,
|
||||
clearModalState,
|
||||
} = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
|
||||
|
||||
@@ -101,13 +106,18 @@ export function NewWebhookModal({
|
||||
const createWebhookMutation = api.webhook.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook created successfully`, type: "success" });
|
||||
showPopup({
|
||||
header: t`Webhook created`,
|
||||
message: t`Webhook created successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to create webhook`,
|
||||
message: error.message || t`Failed to create webhook`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -115,13 +125,18 @@ export function NewWebhookModal({
|
||||
const updateWebhookMutation = api.webhook.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook updated successfully`, type: "success" });
|
||||
showPopup({
|
||||
header: t`Webhook updated`,
|
||||
message: t`Webhook updated successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to update webhook`,
|
||||
message: error.message || t`Failed to update webhook`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -129,19 +144,25 @@ export function NewWebhookModal({
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
showPopup({
|
||||
header: t`Test sent`,
|
||||
message: t`Test webhook sent successfully!`,
|
||||
icon: "success",
|
||||
});
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Test failed`,
|
||||
message: result.error || t`Webhook test failed`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to test webhook`,
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
@@ -151,7 +172,7 @@ export function NewWebhookModal({
|
||||
if (isEdit && webhookPublicId) {
|
||||
updateWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
webhookPublicId: webhookPublicId,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
secret: data.secret || undefined,
|
||||
@@ -174,11 +195,12 @@ export function NewWebhookModal({
|
||||
setIsTestingWebhook(true);
|
||||
testWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
webhookPublicId: webhookPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
|
||||
const isPending =
|
||||
createWebhookMutation.isPending || updateWebhookMutation.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
@@ -231,7 +253,11 @@ export function NewWebhookModal({
|
||||
<Input
|
||||
id="secret"
|
||||
type="password"
|
||||
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
|
||||
placeholder={
|
||||
isEdit
|
||||
? t`Enter new secret to update`
|
||||
: t`HMAC secret for signature verification`
|
||||
}
|
||||
{...register("secret")}
|
||||
errorMessage={errors.secret?.message}
|
||||
/>
|
||||
@@ -252,7 +278,7 @@ export function NewWebhookModal({
|
||||
{webhookEvents.map((event) => (
|
||||
<label
|
||||
key={event}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
className="flex cursor-pointer items-center space-x-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -262,11 +288,11 @@ export function NewWebhookModal({
|
||||
field.onChange([...field.value, event]);
|
||||
} else {
|
||||
field.onChange(
|
||||
field.value.filter((v) => v !== event)
|
||||
field.value.filter((v) => v !== event),
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{event}
|
||||
@@ -277,17 +303,19 @@ export function NewWebhookModal({
|
||||
)}
|
||||
/>
|
||||
{errors.events && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.events.message}</p>
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
{errors.events.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("active")}
|
||||
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Active`}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function UpdateWeekStartDayForm({
|
||||
setValue(newValue);
|
||||
updateWorkspace.mutate({
|
||||
workspacePublicId,
|
||||
weekStartDay: newValue,
|
||||
weekStartDay: newValue as 0 | 1 | 6,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const UpdateWorkspaceUrlForm = ({
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
workspaceUrl: string;
|
||||
workspacePlan: "free" | "pro" | "enterprise";
|
||||
workspacePlan: "free" | "team" | "pro" | "enterprise";
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -24,6 +24,8 @@ export function UpgradeToProConfirmation({
|
||||
body: JSON.stringify({
|
||||
...(entityId && { slug: entityId }),
|
||||
workspacePublicId: workspacePublicId,
|
||||
plan: "pro",
|
||||
billing: "monthly",
|
||||
cancelUrl: "/settings",
|
||||
successUrl: "/settings",
|
||||
}),
|
||||
|
||||
@@ -171,18 +171,20 @@ export default function WebhookList({ workspacePublicId }: WebhookListProps) {
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
showPopup({ header: t`Test sent`, message: t`Test webhook sent successfully!`, icon: "success" });
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Test failed`,
|
||||
message: result.error || t`Webhook test failed`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to test webhook`,
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,6 +39,8 @@ services:
|
||||
|
||||
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
|
||||
- LOG_LEVEL=${LOG_LEVEL}
|
||||
- AXIOM_TOKEN=${AXIOM_TOKEN}
|
||||
- AXIOM_DATASET=${AXIOM_DATASET}
|
||||
|
||||
# Stripe
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
|
||||
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- NEXT_PUBLIC_STORAGE_URL
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
"types": "./dist/utils/rateLimit.d.ts",
|
||||
"default": "./src/utils/rateLimit.ts"
|
||||
},
|
||||
"./utils/apiLogging": {
|
||||
"types": "./dist/utils/apiLogging.d.ts",
|
||||
"default": "./src/utils/apiLogging.ts"
|
||||
},
|
||||
"./utils/permissions": {
|
||||
"types": "./dist/utils/permissions.d.ts",
|
||||
"default": "./src/utils/permissions.ts"
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { attachmentConfirmResponseSchema } from "../schemas";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
|
||||
|
||||
@@ -110,7 +111,7 @@ export const attachmentRouter = createTRPCRouter({
|
||||
size: z.number().positive(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardAttachmentRepo.create>>>())
|
||||
.output(attachmentConfirmResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
} from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import {
|
||||
boardListItemSchema,
|
||||
boardDetailSchema,
|
||||
boardBySlugSchema,
|
||||
boardCreateResponseSchema,
|
||||
boardUpdateResponseSchema,
|
||||
} from "../schemas";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
@@ -37,9 +44,7 @@ export const boardRouter = createTRPCRouter({
|
||||
archived: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
|
||||
)
|
||||
.output(z.array(boardListItemSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -106,7 +111,7 @@ export const boardRouter = createTRPCRouter({
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
|
||||
.output(boardDetailSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -227,7 +232,7 @@ export const boardRouter = createTRPCRouter({
|
||||
boardSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.max(60)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
members: z.array(z.string().min(12)).optional(),
|
||||
labels: z.array(z.string().min(12)).optional(),
|
||||
@@ -246,7 +251,7 @@ export const boardRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
|
||||
.output(boardBySlugSchema.nullable())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const workspace = await workspaceRepo.getBySlugWithBoards(
|
||||
ctx.db,
|
||||
@@ -299,7 +304,7 @@ export const boardRouter = createTRPCRouter({
|
||||
sourceBoardPublicId: z.string().min(12).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
|
||||
.output(boardCreateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -469,7 +474,7 @@ export const boardRouter = createTRPCRouter({
|
||||
isArchived: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
|
||||
.output(boardUpdateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -657,7 +662,7 @@ export const boardRouter = createTRPCRouter({
|
||||
boardSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.max(60)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
boardPublicId: z.string().min(12),
|
||||
}),
|
||||
|
||||
@@ -4,11 +4,20 @@ import { z } from "zod";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as cardCommentRepo from "@kan/db/repository/cardComment.repo";
|
||||
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import {
|
||||
cardCreateResponseSchema,
|
||||
cardUpdateResponseSchema,
|
||||
cardDetailSchema,
|
||||
commentResponseSchema,
|
||||
commentDeleteResponseSchema,
|
||||
activityItemSchema,
|
||||
} from "../schemas";
|
||||
import { mergeActivities } from "../utils/activities";
|
||||
import { sendMentionEmails } from "../utils/notifications";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
@@ -41,7 +50,7 @@ export const cardRouter = createTRPCRouter({
|
||||
dueDate: z.date().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
|
||||
.output(cardCreateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -180,7 +189,7 @@ export const cardRouter = createTRPCRouter({
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
dueDate: input.dueDate ?? null,
|
||||
listId: String(newCard.listId),
|
||||
listId: list.publicId,
|
||||
},
|
||||
{
|
||||
boardId: list.boardPublicId,
|
||||
@@ -214,7 +223,7 @@ export const cardRouter = createTRPCRouter({
|
||||
comment: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.create>>>())
|
||||
.output(commentResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -287,7 +296,7 @@ export const cardRouter = createTRPCRouter({
|
||||
comment: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.update>>>())
|
||||
.output(commentResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -375,7 +384,7 @@ export const cardRouter = createTRPCRouter({
|
||||
commentPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.softDelete>>>())
|
||||
.output(commentDeleteResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -434,7 +443,7 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return deletedComment;
|
||||
return { publicId: input.commentPublicId };
|
||||
}),
|
||||
addOrRemoveLabel: protectedProcedure
|
||||
.meta({
|
||||
@@ -638,25 +647,7 @@ export const cardRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ cardPublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.custom<
|
||||
Omit<
|
||||
NonNullable<
|
||||
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
|
||||
>,
|
||||
"attachments"
|
||||
> & {
|
||||
attachments: {
|
||||
publicId: string;
|
||||
contentType: string;
|
||||
s3Key: string;
|
||||
originalFilename: string | null;
|
||||
size?: number | null;
|
||||
url: string | null;
|
||||
}[];
|
||||
}
|
||||
>(),
|
||||
)
|
||||
.output(cardDetailSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
ctx.db,
|
||||
@@ -762,15 +753,7 @@ export const cardRouter = createTRPCRouter({
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
activities: z.array(
|
||||
z.custom<
|
||||
NonNullable<
|
||||
Awaited<
|
||||
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
|
||||
>
|
||||
>["activities"][number]
|
||||
>(),
|
||||
),
|
||||
activities: z.array(activityItemSchema),
|
||||
hasMore: z.boolean(),
|
||||
nextCursor: z.string().datetime().nullable(),
|
||||
}),
|
||||
@@ -870,7 +853,7 @@ export const cardRouter = createTRPCRouter({
|
||||
dueDate: z.date().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
|
||||
.output(cardUpdateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -905,9 +888,18 @@ export const cardRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
let newListId: number | undefined;
|
||||
let newList:
|
||||
| {
|
||||
id: number;
|
||||
publicId: string;
|
||||
name: string;
|
||||
boardId: number;
|
||||
index: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (input.listPublicId) {
|
||||
const newList = await listRepo.getByPublicId(
|
||||
newList = await listRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.listPublicId,
|
||||
);
|
||||
@@ -1054,8 +1046,19 @@ export const cardRouter = createTRPCRouter({
|
||||
) {
|
||||
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
|
||||
}
|
||||
if (newListId && existingCard.listId !== newListId) {
|
||||
webhookChanges.listId = { from: existingCard.listId, to: newListId };
|
||||
const movedToNewList = Boolean(newListId && existingCard.listId !== newListId);
|
||||
const currentWebhookListPublicId = movedToNewList
|
||||
? input.listPublicId!
|
||||
: existingCard.list.publicId;
|
||||
const currentWebhookListName = movedToNewList
|
||||
? newList?.name ?? card.listName
|
||||
: existingCard.list.name;
|
||||
|
||||
if (movedToNewList) {
|
||||
webhookChanges.listId = {
|
||||
from: existingCard.list.publicId,
|
||||
to: input.listPublicId!,
|
||||
};
|
||||
}
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
@@ -1063,20 +1066,18 @@ export const cardRouter = createTRPCRouter({
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
newListId && existingCard.listId !== newListId
|
||||
? "card.moved"
|
||||
: "card.updated",
|
||||
movedToNewList ? "card.moved" : "card.updated",
|
||||
{
|
||||
id: String(result.id),
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
dueDate: result.dueDate,
|
||||
listId: String(newListId ?? existingCard.listId),
|
||||
listId: currentWebhookListPublicId,
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
listName: currentWebhookListName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
@@ -1166,12 +1167,12 @@ export const cardRouter = createTRPCRouter({
|
||||
title: fullCard.title,
|
||||
description: fullCard.description,
|
||||
dueDate: fullCard.dueDate,
|
||||
listId: String(fullCard.listId),
|
||||
listId: fullCard.list.publicId,
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
listName: fullCard.list.name,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
@@ -1184,4 +1185,176 @@ export const cardRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
duplicate: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Duplicate a card",
|
||||
method: "POST",
|
||||
path: "/cards/{cardPublicId}/duplicate",
|
||||
description: "Duplicates a card to a target list with optional options",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
listPublicId: z.string().min(12),
|
||||
index: z.number().int().min(0).optional(),
|
||||
title: z.string().min(1).max(2000).optional(),
|
||||
copyLabels: z.boolean(),
|
||||
copyMembers: z.boolean(),
|
||||
copyChecklists: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const sourceCardMeta = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!sourceCardMeta)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(
|
||||
ctx.db,
|
||||
userId,
|
||||
sourceCardMeta.workspaceId,
|
||||
"card:create",
|
||||
);
|
||||
|
||||
const targetList = await listRepo.getWorkspaceAndListIdByListPublicId(
|
||||
ctx.db,
|
||||
input.listPublicId,
|
||||
);
|
||||
|
||||
if (!targetList)
|
||||
throw new TRPCError({
|
||||
message: `List with public ID ${input.listPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (targetList.workspaceId !== sourceCardMeta.workspaceId)
|
||||
throw new TRPCError({
|
||||
message: `Target list must be in the same workspace`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
const sourceCard = await cardRepo.getWithListAndMembersByPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!sourceCard)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const newCard = await cardRepo.create(ctx.db, {
|
||||
title: input.title ?? sourceCard.title,
|
||||
description: sourceCard.description ?? "",
|
||||
createdBy: userId,
|
||||
listId: targetList.id,
|
||||
position: "end",
|
||||
dueDate: sourceCard.dueDate ?? null,
|
||||
});
|
||||
|
||||
if (input.index !== undefined && input.index >= 0) {
|
||||
await cardRepo.reorder(ctx.db, {
|
||||
cardId: newCard.id,
|
||||
newIndex: input.index,
|
||||
newListId: targetList.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.copyLabels && sourceCard.labels?.length) {
|
||||
const labelPublicIds = sourceCard.labels.map((l) => l.publicId);
|
||||
const labels = await labelRepo.getAllByPublicIds(ctx.db, labelPublicIds);
|
||||
if (labels.length) {
|
||||
const labelsInsert = labels.map((label) => ({
|
||||
cardId: newCard.id,
|
||||
labelId: label.id,
|
||||
}));
|
||||
await cardRepo.bulkCreateCardLabelRelationships(ctx.db, labelsInsert);
|
||||
const cardActivitesInsert = labels.map((cardLabel) => ({
|
||||
type: "card.updated.label.added" as const,
|
||||
cardId: newCard.id,
|
||||
labelId: cardLabel.id,
|
||||
createdBy: userId,
|
||||
}));
|
||||
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.copyMembers && sourceCard.members?.length) {
|
||||
const memberPublicIds = sourceCard.members.map((m) => m.publicId);
|
||||
const members = await workspaceRepo.getAllMembersByPublicIds(
|
||||
ctx.db,
|
||||
memberPublicIds,
|
||||
);
|
||||
if (members.length) {
|
||||
const membersInsert = members.map((member) => ({
|
||||
cardId: newCard.id,
|
||||
workspaceMemberId: member.id,
|
||||
}));
|
||||
await cardRepo.bulkCreateCardWorkspaceMemberRelationships(
|
||||
ctx.db,
|
||||
membersInsert,
|
||||
);
|
||||
const cardActivitesInsert = members.map((member) => ({
|
||||
type: "card.updated.member.added" as const,
|
||||
cardId: newCard.id,
|
||||
workspaceMemberId: member.id,
|
||||
createdBy: userId,
|
||||
}));
|
||||
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.copyChecklists && sourceCard.checklists?.length) {
|
||||
for (const checklist of sourceCard.checklists) {
|
||||
const newChecklist = await checklistRepo.create(ctx.db, {
|
||||
cardId: newCard.id,
|
||||
name: checklist.name,
|
||||
createdBy: userId,
|
||||
});
|
||||
if (!newChecklist?.id) continue;
|
||||
if (checklist.items?.length) {
|
||||
for (const item of checklist.items) {
|
||||
await checklistRepo.createItem(ctx.db, {
|
||||
checklistId: newChecklist.id,
|
||||
title: item.title,
|
||||
createdBy: userId,
|
||||
completed: item.completed ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.checklist.added",
|
||||
cardId: newCard.id,
|
||||
toTitle: newChecklist.name,
|
||||
createdBy: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { publicId: newCard.publicId };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as activityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { listCreateResponseSchema, listUpdateResponseSchema } from "../schemas";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
|
||||
export const listRouter = createTRPCRouter({
|
||||
@@ -27,7 +28,7 @@ export const listRouter = createTRPCRouter({
|
||||
boardPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof listRepo.create>>>())
|
||||
.output(listCreateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -163,12 +164,7 @@ export const listRouter = createTRPCRouter({
|
||||
index: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.custom<
|
||||
| Awaited<ReturnType<typeof listRepo.update>>
|
||||
| Awaited<ReturnType<typeof listRepo.reorder>>
|
||||
>(),
|
||||
)
|
||||
.output(listUpdateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { memberInviteResponseSchema } from "../schemas";
|
||||
import {
|
||||
assertCanManageMember,
|
||||
assertCanManageRole,
|
||||
@@ -40,7 +41,7 @@ export const memberRouter = createTRPCRouter({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof memberRepo.create>>>())
|
||||
.output(memberInviteResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
|
||||
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import {
|
||||
workspaceListItemSchema,
|
||||
workspaceDetailSchema,
|
||||
workspaceWithBoardsSchema,
|
||||
workspaceCreateResponseSchema,
|
||||
workspaceUpdateResponseSchema,
|
||||
workspaceDeleteResponseSchema,
|
||||
} from "../schemas";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
|
||||
export const workspaceRouter = createTRPCRouter({
|
||||
@@ -22,9 +30,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof workspaceRepo.getAllByUserId>>>(),
|
||||
)
|
||||
.output(z.array(workspaceListItemSchema))
|
||||
.query(async ({ ctx }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -50,11 +56,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.custom<
|
||||
Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>
|
||||
>(),
|
||||
)
|
||||
.output(workspaceDetailSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -134,7 +136,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
return {
|
||||
...result,
|
||||
members: sanitizedMembers,
|
||||
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -162,9 +164,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof workspaceRepo.getBySlugWithBoards>>>(),
|
||||
)
|
||||
.output(workspaceWithBoardsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -202,6 +202,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
description: z.string().max(280).optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.min(3)
|
||||
@@ -210,7 +211,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.create>>>())
|
||||
.output(workspaceCreateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
const userEmail = ctx.user?.email;
|
||||
@@ -260,6 +261,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
slug: workspaceSlug,
|
||||
createdBy: userId,
|
||||
createdByEmail: userEmail,
|
||||
...(input.description && { description: input.description }),
|
||||
});
|
||||
|
||||
if (!result.publicId)
|
||||
@@ -268,7 +270,13 @@ export const workspaceRouter = createTRPCRouter({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
return {
|
||||
publicId: result.publicId,
|
||||
name: result.name!,
|
||||
slug: result.slug!,
|
||||
description: result.description ?? null,
|
||||
plan: result.plan!,
|
||||
};
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
@@ -298,7 +306,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
|
||||
.output(workspaceUpdateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -381,7 +389,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.hardDelete>>>())
|
||||
.output(workspaceDeleteResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -403,12 +411,12 @@ export const workspaceRouter = createTRPCRouter({
|
||||
});
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
|
||||
|
||||
const result = await workspaceRepo.hardDelete(
|
||||
await workspaceRepo.hardDelete(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
return result;
|
||||
return { success: true };
|
||||
}),
|
||||
checkSlugAvailability: publicProcedure
|
||||
.meta({
|
||||
|
||||
12
packages/api/src/schemas/attachment.ts
Normal file
12
packages/api/src/schemas/attachment.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── attachment.confirm ──────────────────────────────────────
|
||||
export const attachmentConfirmResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
filename: z.string(),
|
||||
originalFilename: z.string(),
|
||||
contentType: z.string(),
|
||||
size: z.number(),
|
||||
s3Key: z.string(),
|
||||
createdAt: z.date(),
|
||||
});
|
||||
130
packages/api/src/schemas/board.ts
Normal file
130
packages/api/src/schemas/board.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
checklistResponseSchema,
|
||||
labelSchema,
|
||||
workspaceMemberSchema,
|
||||
} from "./common";
|
||||
|
||||
// ─── board.all ───────────────────────────────────────────────
|
||||
export const boardListItemSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
favorite: z.boolean(),
|
||||
lists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
}),
|
||||
),
|
||||
labels: z.array(labelSchema),
|
||||
});
|
||||
|
||||
// ─── Card sub-object inside board detail (byId) ─────────────
|
||||
const boardCardMemberSchema = z.object({
|
||||
publicId: z.string(),
|
||||
email: z.string(),
|
||||
user: z
|
||||
.object({
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
image: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const boardDetailCardSchema = z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
index: z.number(),
|
||||
dueDate: z.date().nullable(),
|
||||
labels: z.array(labelSchema),
|
||||
members: z.array(boardCardMemberSchema),
|
||||
attachments: z.array(z.object({ publicId: z.string() })),
|
||||
checklists: z.array(checklistResponseSchema),
|
||||
comments: z.array(z.object({ publicId: z.string() })),
|
||||
});
|
||||
|
||||
// ─── board.byId ──────────────────────────────────────────────
|
||||
export const boardDetailSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
visibility: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
favorite: z.boolean(),
|
||||
workspace: z.object({
|
||||
publicId: z.string(),
|
||||
members: z.array(workspaceMemberSchema),
|
||||
}),
|
||||
labels: z.array(labelSchema),
|
||||
lists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
cards: z.array(boardDetailCardSchema),
|
||||
}),
|
||||
),
|
||||
allLists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ─── Card sub-object inside board detail (bySlug — no members) ─
|
||||
const boardSlugCardSchema = z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
index: z.number(),
|
||||
dueDate: z.date().nullable(),
|
||||
labels: z.array(labelSchema),
|
||||
attachments: z.array(z.object({ publicId: z.string() })),
|
||||
checklists: z.array(checklistResponseSchema),
|
||||
comments: z.array(z.object({ publicId: z.string() })),
|
||||
});
|
||||
|
||||
// ─── board.bySlug ────────────────────────────────────────────
|
||||
export const boardBySlugSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
visibility: z.string(),
|
||||
workspace: z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
}),
|
||||
labels: z.array(labelSchema),
|
||||
lists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
cards: z.array(boardSlugCardSchema),
|
||||
}),
|
||||
),
|
||||
allLists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ─── board.create / board.createFromSnapshot ─────────────────
|
||||
export const boardCreateResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
// ─── board.update ────────────────────────────────────────────
|
||||
export const boardUpdateResponseSchema = z.union([
|
||||
z.object({ success: z.boolean() }),
|
||||
z.object({ publicId: z.string(), name: z.string() }),
|
||||
]);
|
||||
216
packages/api/src/schemas/card.ts
Normal file
216
packages/api/src/schemas/card.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
checklistResponseSchema,
|
||||
labelSchema,
|
||||
workspaceMemberSchema,
|
||||
} from "./common";
|
||||
|
||||
// ─── card.create ─────────────────────────────────────────────
|
||||
export const cardCreateResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
});
|
||||
|
||||
// ─── card.update ─────────────────────────────────────────────
|
||||
export const cardUpdateResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
dueDate: z.date().nullable(),
|
||||
});
|
||||
|
||||
// ─── Comment responses ───────────────────────────────────────
|
||||
export const commentResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
comment: z.string(),
|
||||
});
|
||||
|
||||
export const commentDeleteResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
});
|
||||
|
||||
// ─── card.byId ───────────────────────────────────────────────
|
||||
|
||||
const cardMemberSchema = z.object({
|
||||
publicId: z.string(),
|
||||
email: z.string(),
|
||||
user: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const cardDetailSchema = z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
dueDate: z.date().nullable(),
|
||||
createdBy: z.string().nullable(),
|
||||
labels: z.array(labelSchema),
|
||||
attachments: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
contentType: z.string(),
|
||||
s3Key: z.string(),
|
||||
originalFilename: z.string().nullable(),
|
||||
size: z.number().nullable(),
|
||||
url: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
checklists: z.array(checklistResponseSchema),
|
||||
list: z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
board: z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
labels: z.array(labelSchema),
|
||||
lists: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
workspace: z.object({
|
||||
publicId: z.string(),
|
||||
members: z.array(workspaceMemberSchema),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
members: z.array(cardMemberSchema),
|
||||
activities: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
type: z.string(),
|
||||
createdAt: z.date(),
|
||||
fromIndex: z.number().nullable(),
|
||||
toIndex: z.number().nullable(),
|
||||
fromTitle: z.string().nullable(),
|
||||
toTitle: z.string().nullable(),
|
||||
fromDescription: z.string().nullable(),
|
||||
toDescription: z.string().nullable(),
|
||||
fromDueDate: z.date().nullable(),
|
||||
toDueDate: z.date().nullable(),
|
||||
fromList: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
toList: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
label: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
member: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
user: z
|
||||
.object({
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
user: z
|
||||
.object({
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
comment: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
comment: z.string(),
|
||||
createdBy: z.string().nullable(),
|
||||
updatedAt: z.date().nullable(),
|
||||
deletedAt: z.date().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ─── card.getActivities ──────────────────────────────────────
|
||||
export const activityItemSchema = z.object({
|
||||
publicId: z.string(),
|
||||
type: z.string(),
|
||||
createdAt: z.date(),
|
||||
fromIndex: z.number().nullable(),
|
||||
toIndex: z.number().nullable(),
|
||||
fromTitle: z.string().nullable(),
|
||||
toTitle: z.string().nullable(),
|
||||
fromDescription: z.string().nullable(),
|
||||
toDescription: z.string().nullable(),
|
||||
fromDueDate: z.date().nullable(),
|
||||
toDueDate: z.date().nullable(),
|
||||
fromList: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
toList: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
label: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
member: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
user: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
image: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
user: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
image: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
comment: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
comment: z.string(),
|
||||
createdBy: z.string().nullable(),
|
||||
updatedAt: z.date().nullable(),
|
||||
deletedAt: z.date().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
attachment: z
|
||||
.object({
|
||||
publicId: z.string(),
|
||||
filename: z.string(),
|
||||
originalFilename: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
40
packages/api/src/schemas/common.ts
Normal file
40
packages/api/src/schemas/common.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Shared label schema used across board and card responses
|
||||
export const labelSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
colourCode: z.string().nullable(),
|
||||
});
|
||||
|
||||
// Shared checklist item schema
|
||||
export const checklistItemResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
completed: z.boolean(),
|
||||
index: z.number(),
|
||||
});
|
||||
|
||||
// Shared checklist schema with items
|
||||
export const checklistResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
index: z.number(),
|
||||
items: z.array(checklistItemResponseSchema),
|
||||
});
|
||||
|
||||
// Shared user sub-object (used inside member/workspace responses)
|
||||
export const userSchema = z.object({
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
image: z.string().nullable(),
|
||||
});
|
||||
|
||||
// Workspace member sub-object
|
||||
export const workspaceMemberSchema = z.object({
|
||||
publicId: z.string(),
|
||||
email: z.string(),
|
||||
status: z.string(),
|
||||
user: userSchema.nullable(),
|
||||
});
|
||||
39
packages/api/src/schemas/index.ts
Normal file
39
packages/api/src/schemas/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export {
|
||||
boardListItemSchema,
|
||||
boardDetailSchema,
|
||||
boardBySlugSchema,
|
||||
boardCreateResponseSchema,
|
||||
boardUpdateResponseSchema,
|
||||
} from "./board";
|
||||
|
||||
export {
|
||||
cardCreateResponseSchema,
|
||||
cardUpdateResponseSchema,
|
||||
cardDetailSchema,
|
||||
commentResponseSchema,
|
||||
commentDeleteResponseSchema,
|
||||
activityItemSchema,
|
||||
} from "./card";
|
||||
|
||||
export {
|
||||
labelSchema,
|
||||
checklistItemResponseSchema,
|
||||
checklistResponseSchema,
|
||||
userSchema,
|
||||
workspaceMemberSchema,
|
||||
} from "./common";
|
||||
|
||||
export {
|
||||
workspaceListItemSchema,
|
||||
workspaceDetailSchema,
|
||||
workspaceWithBoardsSchema,
|
||||
workspaceCreateResponseSchema,
|
||||
workspaceUpdateResponseSchema,
|
||||
workspaceDeleteResponseSchema,
|
||||
} from "./workspace";
|
||||
|
||||
export { listCreateResponseSchema, listUpdateResponseSchema } from "./list";
|
||||
|
||||
export { memberInviteResponseSchema } from "./member";
|
||||
|
||||
export { attachmentConfirmResponseSchema } from "./attachment";
|
||||
13
packages/api/src/schemas/list.ts
Normal file
13
packages/api/src/schemas/list.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── list.create ─────────────────────────────────────────────
|
||||
export const listCreateResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
// ─── list.update / list.reorder ──────────────────────────────
|
||||
export const listUpdateResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
6
packages/api/src/schemas/member.ts
Normal file
6
packages/api/src/schemas/member.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── member.invite ───────────────────────────────────────────
|
||||
export const memberInviteResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
});
|
||||
93
packages/api/src/schemas/workspace.ts
Normal file
93
packages/api/src/schemas/workspace.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { userSchema } from "./common";
|
||||
|
||||
// ─── workspace.all ───────────────────────────────────────────
|
||||
export const workspaceListItemSchema = z.object({
|
||||
role: z.string(),
|
||||
workspace: z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
slug: z.string(),
|
||||
plan: z.enum(["free", "team", "pro", "enterprise"]),
|
||||
weekStartDay: z.number().nullable(),
|
||||
deletedAt: z.date().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
// ─── workspace.byId ──────────────────────────────────────────
|
||||
const workspaceMemberDetailSchema = z.object({
|
||||
publicId: z.string(),
|
||||
email: z.string().optional(),
|
||||
role: z.string(),
|
||||
status: z.string(),
|
||||
createdAt: z.date(),
|
||||
user: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string().optional(),
|
||||
image: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const workspaceSubscriptionSchema = z.object({
|
||||
plan: z.string(),
|
||||
status: z.string(),
|
||||
seats: z.number().nullable(),
|
||||
unlimitedSeats: z.boolean().nullable(),
|
||||
periodStart: z.date().nullable(),
|
||||
periodEnd: z.date().nullable(),
|
||||
});
|
||||
|
||||
export const workspaceDetailSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
showEmailsToMembers: z.boolean().nullable(),
|
||||
weekStartDay: z.number().nullable(),
|
||||
members: z.array(workspaceMemberDetailSchema),
|
||||
subscriptions: z.array(workspaceSubscriptionSchema),
|
||||
});
|
||||
|
||||
// ─── workspace.bySlug ────────────────────────────────────────
|
||||
export const workspaceWithBoardsSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
slug: z.string(),
|
||||
boards: z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ─── workspace.create ────────────────────────────────────────
|
||||
export const workspaceCreateResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string().nullable(),
|
||||
plan: z.enum(["free", "team", "pro", "enterprise"]),
|
||||
});
|
||||
|
||||
// ─── workspace.update ────────────────────────────────────────
|
||||
export const workspaceUpdateResponseSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string().nullable(),
|
||||
plan: z.enum(["free", "team", "pro", "enterprise"]),
|
||||
showEmailsToMembers: z.boolean().nullable(),
|
||||
weekStartDay: z.number().nullable(),
|
||||
});
|
||||
|
||||
// ─── workspace.delete ────────────────────────────────────────
|
||||
export const workspaceDeleteResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
import type { NextApiRequest } from "next";
|
||||
import type { OpenApiMeta } from "trpc-to-openapi";
|
||||
@@ -11,7 +12,28 @@ import { initAuth } from "@kan/auth/server";
|
||||
import { createDrizzleClient } from "@kan/db/client";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
const log = createLogger("trpc");
|
||||
const log = createLogger("api");
|
||||
|
||||
const TRPC_STATUS_MAP: Partial<Record<TRPCError["code"], number>> = {
|
||||
PARSE_ERROR: 400,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
METHOD_NOT_SUPPORTED: 405,
|
||||
TIMEOUT: 408,
|
||||
CONFLICT: 409,
|
||||
PRECONDITION_FAILED: 412,
|
||||
PAYLOAD_TOO_LARGE: 413,
|
||||
UNPROCESSABLE_CONTENT: 422,
|
||||
TOO_MANY_REQUESTS: 429,
|
||||
CLIENT_CLOSED_REQUEST: 499,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
NOT_IMPLEMENTED: 501,
|
||||
BAD_GATEWAY: 502,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
GATEWAY_TIMEOUT: 504,
|
||||
};
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
@@ -50,6 +72,7 @@ interface CreateContextOptions {
|
||||
db: dbClient;
|
||||
auth: ReturnType<typeof createAuthWithHeaders>;
|
||||
headers: Headers;
|
||||
transport?: "trpc" | "rest";
|
||||
}
|
||||
|
||||
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
||||
@@ -58,6 +81,8 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
||||
db: opts.db,
|
||||
auth: opts.auth,
|
||||
headers: opts.headers,
|
||||
transport: opts.transport ?? "trpc",
|
||||
requestId: randomUUID(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -69,7 +94,13 @@ export const createTRPCContext = async ({ req }: CreateNextContextOptions) => {
|
||||
|
||||
const session = await auth.api.getSession();
|
||||
|
||||
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
|
||||
return createInnerTRPCContext({
|
||||
db,
|
||||
user: session?.user,
|
||||
auth,
|
||||
headers,
|
||||
transport: "trpc",
|
||||
});
|
||||
};
|
||||
|
||||
export const createNextApiContext = async (req: NextApiRequest) => {
|
||||
@@ -80,7 +111,13 @@ export const createNextApiContext = async (req: NextApiRequest) => {
|
||||
|
||||
const session = await auth.api.getSession();
|
||||
|
||||
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
|
||||
return createInnerTRPCContext({
|
||||
db,
|
||||
user: session?.user,
|
||||
auth,
|
||||
headers,
|
||||
transport: "trpc",
|
||||
});
|
||||
};
|
||||
|
||||
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
||||
@@ -93,11 +130,16 @@ export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
||||
try {
|
||||
session = await auth.api.getSession();
|
||||
} catch (error) {
|
||||
log.error({ err: error }, "Error getting session");
|
||||
throw error;
|
||||
log.warn({ err: error }, "Failed to get session, treating as unauthenticated");
|
||||
}
|
||||
|
||||
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
|
||||
return createInnerTRPCContext({
|
||||
db,
|
||||
user: session?.user,
|
||||
auth,
|
||||
headers,
|
||||
transport: "rest",
|
||||
});
|
||||
};
|
||||
|
||||
const t = initTRPC
|
||||
@@ -121,17 +163,39 @@ export const createTRPCRouter = t.router;
|
||||
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
|
||||
const loggingMiddleware = t.middleware(async ({ path, type, next, ctx }) => {
|
||||
const loggingMiddleware = t.middleware(async ({ path, type, next, ctx, getRawInput }) => {
|
||||
const start = Date.now();
|
||||
const result = await next();
|
||||
const [result, input] = await Promise.all([next(), getRawInput().catch(() => undefined)]);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
const meta = { procedure: path, type, duration, userId: (ctx as { user?: { id: string } }).user?.id };
|
||||
const { user, transport, requestId } = ctx as {
|
||||
user?: { id: string; email: string };
|
||||
transport?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
const isCloud = process.env.NEXT_PUBLIC_KAN_ENV === "cloud";
|
||||
const meta = {
|
||||
requestId,
|
||||
procedure: path,
|
||||
type,
|
||||
transport,
|
||||
duration,
|
||||
userId: user?.id,
|
||||
...(isCloud && { email: user?.email }),
|
||||
input,
|
||||
};
|
||||
|
||||
const label = transport === "rest" ? "REST" : "tRPC";
|
||||
|
||||
if (result.ok) {
|
||||
log.info(meta, "tRPC OK");
|
||||
log.info({ ...meta, status: 200 }, `${label} OK`);
|
||||
} else {
|
||||
log.error({ ...meta, err: result.error }, "tRPC error");
|
||||
const status = TRPC_STATUS_MAP[result.error.code] ?? 500;
|
||||
const errorCode = result.error.code;
|
||||
log.error(
|
||||
{ ...meta, status, errorCode, err: result.error },
|
||||
`${label} error`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
80
packages/api/src/utils/apiLogging.ts
Normal file
80
packages/api/src/utils/apiLogging.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
import { createNextApiContext } from "../trpc";
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
const isCloud = process.env.NEXT_PUBLIC_KAN_ENV === "cloud";
|
||||
|
||||
export function withApiLogging(
|
||||
handler: (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) => Promise<unknown> | unknown,
|
||||
) {
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const start = Date.now();
|
||||
const requestId = randomUUID();
|
||||
const route = req.url?.split("?")[0] ?? "unknown";
|
||||
const input = {
|
||||
...(req.query &&
|
||||
Object.keys(req.query).length > 0 && { query: req.query }),
|
||||
...(req.body &&
|
||||
typeof req.body === "object" &&
|
||||
Object.keys(req.body).length > 0 && { body: req.body }),
|
||||
};
|
||||
|
||||
let statusCode = 200;
|
||||
const originalStatus = res.status.bind(res);
|
||||
res.status = (code: number) => {
|
||||
statusCode = code;
|
||||
return originalStatus(code);
|
||||
};
|
||||
|
||||
let userId: string | undefined;
|
||||
let email: string | undefined;
|
||||
try {
|
||||
const ctx = await createNextApiContext(req);
|
||||
userId = ctx.user?.id;
|
||||
email = ctx.user?.email ?? undefined;
|
||||
} catch {
|
||||
// unauthenticated or auth unavailable
|
||||
}
|
||||
|
||||
let handlerError: unknown;
|
||||
try {
|
||||
await handler(req, res);
|
||||
} catch (err) {
|
||||
handlerError = err;
|
||||
statusCode = 500;
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
const meta = {
|
||||
requestId,
|
||||
procedure: route,
|
||||
transport: "rest",
|
||||
duration,
|
||||
userId,
|
||||
...(isCloud && email && { email }),
|
||||
...(Object.keys(input).length > 0 && { input }),
|
||||
status: statusCode,
|
||||
...(handlerError instanceof Error && {
|
||||
error: handlerError.message,
|
||||
stack: handlerError.stack,
|
||||
}),
|
||||
};
|
||||
|
||||
if (statusCode < 400) {
|
||||
log.info(meta, "API OK");
|
||||
} else {
|
||||
log.error(meta, "API error");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const { mockLogger } = vi.hoisted(() => ({
|
||||
mockLogger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/webhook.repo", () => ({
|
||||
getActiveByWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/logger", () => ({
|
||||
createLogger: vi.fn(() => mockLogger),
|
||||
}));
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import {
|
||||
sendWebhookToUrl,
|
||||
@@ -155,6 +166,33 @@ describe("webhook utilities", () => {
|
||||
title: { from: "Old Title", to: "Updated Title" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves public list IDs in moved payloads", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.moved",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Moved Card",
|
||||
listId: "list-public-done",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
listName: "Done",
|
||||
changes: {
|
||||
listId: { from: "list-public-backlog", to: "list-public-done" },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.card.listId).toBe("list-public-done");
|
||||
expect(payload.data.list).toEqual({
|
||||
id: "list-public-done",
|
||||
name: "Done",
|
||||
});
|
||||
expect(payload.data.changes).toEqual({
|
||||
listId: { from: "list-public-backlog", to: "list-public-done" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendWebhookToUrl", () => {
|
||||
@@ -434,8 +472,6 @@ describe("webhook utilities", () => {
|
||||
});
|
||||
|
||||
it("continues sending to other webhooks when one fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
@@ -462,11 +498,15 @@ describe("webhook utilities", () => {
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Webhook delivery failed"),
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/webhook1",
|
||||
event: "card.created",
|
||||
error: "500 Error",
|
||||
statusCode: 500,
|
||||
}),
|
||||
"Webhook delivery failed",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles empty webhook list", async () => {
|
||||
@@ -478,9 +518,6 @@ describe("webhook utilities", () => {
|
||||
});
|
||||
|
||||
it("catches and logs DB errors without throwing", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
mockGetActiveByWorkspaceId.mockRejectedValueOnce(
|
||||
new Error("DB connection failed"),
|
||||
);
|
||||
@@ -490,12 +527,13 @@ describe("webhook utilities", () => {
|
||||
sendWebhooksForWorkspace(mockDb, 1, mockPayload),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to send webhooks for workspace:",
|
||||
expect.any(Error),
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
workspaceId: 1,
|
||||
}),
|
||||
"Failed to send webhooks for workspace",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export function createPlugins(db: dbClient) {
|
||||
if (authorization?.startsWith("Bearer ")) {
|
||||
return authorization.slice(7);
|
||||
}
|
||||
return ctx.headers?.get("x-api-key") ?? undefined;
|
||||
return ctx.headers?.get("x-api-key") ?? null;
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
|
||||
@@ -92,14 +92,6 @@ export const socialProvidersPlugin = () => ({
|
||||
const providers = ctx.context.socialProviders.map((p) =>
|
||||
p.id.toLowerCase(),
|
||||
);
|
||||
// Add OIDC provider if configured
|
||||
if (
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_DISCOVERY_URL
|
||||
) {
|
||||
providers.push("oidc");
|
||||
}
|
||||
return ctx.json(providers);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."workspace_plan" ADD VALUE 'team' BEFORE 'pro';
|
||||
3869
packages/db/migrations/meta/20260402225628_snapshot.json
Normal file
3869
packages/db/migrations/meta/20260402225628_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,13 @@
|
||||
"when": 1773212242728,
|
||||
"tag": "20260311065722_AddWeekStartDay",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1775170588247,
|
||||
"tag": "20260402225628_AddTeamWorkspacePlan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -220,6 +220,7 @@ export const getByPublicId = async (
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
@@ -278,6 +279,7 @@ export const getByPublicId = async (
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
|
||||
@@ -241,6 +241,14 @@ export const getByPublicId = (db: dbClient, cardPublicId: string) => {
|
||||
listId: true,
|
||||
dueDate: true,
|
||||
},
|
||||
with: {
|
||||
list: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: eq(cards.publicId, cardPublicId),
|
||||
});
|
||||
};
|
||||
@@ -945,7 +953,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
||||
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
|
||||
with: {
|
||||
list: {
|
||||
columns: { name: true },
|
||||
columns: { name: true, publicId: true },
|
||||
with: {
|
||||
board: {
|
||||
columns: {
|
||||
@@ -966,6 +974,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
||||
createdBy: result.createdBy,
|
||||
workspaceId: result.list.board.workspaceId,
|
||||
workspaceVisibility: result.list.board.visibility,
|
||||
listPublicId: result.list.publicId,
|
||||
listName: result.list.name,
|
||||
boardPublicId: result.list.board.publicId,
|
||||
boardName: result.list.board.name,
|
||||
|
||||
@@ -209,10 +209,12 @@ export const getByPublicId = async (db: dbClient, listPublicId: string) => {
|
||||
return db.query.lists.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
publicId: true,
|
||||
name: true,
|
||||
boardId: true,
|
||||
index: true,
|
||||
},
|
||||
where: eq(lists.publicId, listPublicId),
|
||||
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -433,6 +435,7 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
||||
return result
|
||||
? {
|
||||
id: result.id,
|
||||
publicId: listPublicId,
|
||||
name: result.name,
|
||||
createdBy: result.createdBy,
|
||||
workspaceId: result.board.workspaceId,
|
||||
|
||||
@@ -64,6 +64,8 @@ export const create = async (
|
||||
slug: string;
|
||||
createdBy: string;
|
||||
createdByEmail: string;
|
||||
description?: string;
|
||||
plan?: "free" | "team" | "pro" | "enterprise";
|
||||
},
|
||||
) => {
|
||||
const [workspace] = await db
|
||||
@@ -73,6 +75,8 @@ export const create = async (
|
||||
name: workspaceInput.name,
|
||||
slug: workspaceInput.slug,
|
||||
createdBy: workspaceInput.createdBy,
|
||||
...(workspaceInput.description && { description: workspaceInput.description }),
|
||||
...(workspaceInput.plan && { plan: workspaceInput.plan }),
|
||||
})
|
||||
.returning({
|
||||
id: workspaces.id,
|
||||
@@ -124,7 +128,7 @@ export const update = async (
|
||||
workspaceInput: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
plan?: "free" | "pro" | "enterprise";
|
||||
plan?: "free" | "team" | "pro" | "enterprise";
|
||||
description?: string;
|
||||
showEmailsToMembers?: boolean;
|
||||
weekStartDay?: number;
|
||||
|
||||
@@ -34,7 +34,7 @@ export const slugTypes = ["reserved", "premium"] as const;
|
||||
export type SlugType = (typeof slugTypes)[number];
|
||||
export const slugTypeEnum = pgEnum("slug_type", slugTypes);
|
||||
|
||||
export const workspacePlans = ["free", "pro", "enterprise"] as const;
|
||||
export const workspacePlans = ["free", "team", "pro", "enterprise"] as const;
|
||||
export type WorkspacePlan = (typeof workspacePlans)[number];
|
||||
export const workspacePlanEnum = pgEnum("workspace_plan", workspacePlans);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@axiomhq/js": "^1.4.0",
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.3"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user