Compare commits
587 Commits
fix/guide-
...
feat/team
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d735df233 | ||
|
|
1ecab3981e | ||
|
|
7ea9943544 | ||
|
|
d2f76c4726 | ||
|
|
758072cca9 | ||
|
|
bbe8125fc1 | ||
|
|
c565dfa5a7 | ||
|
|
1b8cf1734e | ||
|
|
8376b543cc | ||
|
|
cf31795f58 | ||
|
|
0dd21e15d1 | ||
|
|
598ee44168 | ||
|
|
57de0720c5 | ||
|
|
ae14d560a1 | ||
|
|
c5e18ed461 | ||
|
|
f7a33d8991 | ||
|
|
b65982d7e1 | ||
|
|
76facf70ec | ||
|
|
1e8751deae | ||
|
|
2103102c96 | ||
|
|
2328931bc9 | ||
|
|
340733d979 | ||
|
|
9dbcb50161 | ||
|
|
02c79dca56 | ||
|
|
805699f956 | ||
|
|
02dd91e35a | ||
|
|
6157ec9c35 | ||
|
|
3b318b4ca7 | ||
|
|
65a98de213 | ||
|
|
1254e8e7f9 | ||
|
|
10d105241f | ||
|
|
23651e070a | ||
|
|
e8aaee7375 | ||
|
|
952a514d1d | ||
|
|
1303167be4 | ||
|
|
d4995a9092 | ||
|
|
783bfeea36 | ||
|
|
87ba7252e7 | ||
|
|
442bf489c8 | ||
|
|
b6682710dd | ||
|
|
8f8f26514b | ||
|
|
df3a66fe22 | ||
|
|
fa5a71e4b9 | ||
|
|
339cd8cc1e | ||
|
|
52670089af | ||
|
|
c9599ef9e3 | ||
|
|
8fdd9ea653 | ||
|
|
db07a161ae | ||
|
|
edb79d466c | ||
|
|
18d2e18bb3 | ||
|
|
42d43baa71 | ||
|
|
55c0233dc2 | ||
|
|
180c990bb6 | ||
|
|
72efdca722 | ||
|
|
d9b386e9f1 | ||
|
|
1501fa631e | ||
|
|
fdb2c23911 | ||
|
|
3f00c70c36 | ||
|
|
762dff1d42 | ||
|
|
6405744ec6 | ||
|
|
a8c20ba372 | ||
|
|
71bb3d4d3f | ||
|
|
0de4b38bb3 | ||
|
|
ac585b3f0b | ||
|
|
e214119c45 | ||
|
|
5f43ae1e61 | ||
|
|
6f3693bf1b | ||
|
|
3bbb1d7541 | ||
|
|
18b0563c75 | ||
|
|
0c64223ec1 | ||
|
|
3a022926de | ||
|
|
93a6ae3f81 | ||
|
|
42b3595367 | ||
|
|
39278cc97b | ||
|
|
102ec5021f | ||
|
|
62c24ca8e1 | ||
|
|
2914433f0f | ||
|
|
afd319f88f | ||
|
|
b06f08fc67 | ||
|
|
c83d20d63c | ||
|
|
485182a9cf | ||
|
|
6716c0375d | ||
|
|
3f763051bb | ||
|
|
fef7a2e6e0 | ||
|
|
75891d5eb1 | ||
|
|
6e8770c8c4 | ||
|
|
3457f7495a | ||
|
|
07acb17459 | ||
|
|
77cd0ecf26 | ||
|
|
eccc0302f2 | ||
|
|
7274d8a54e | ||
|
|
8d19be6232 | ||
|
|
e0828d11bf | ||
|
|
9e7a37d079 | ||
|
|
76f1592615 | ||
|
|
80e80e7d9b | ||
|
|
8692f05f14 | ||
|
|
fdf67ae9f5 | ||
|
|
e5705bd6cc | ||
|
|
9c7dd705c3 | ||
|
|
62a9fbbc0e | ||
|
|
dec256866e | ||
|
|
a1df2ff992 | ||
|
|
8bd092e489 | ||
|
|
f52e6df410 | ||
|
|
843445e59d | ||
|
|
6fe4db8109 | ||
|
|
528cf0f380 | ||
|
|
b06655ecb3 | ||
|
|
e3b057dde7 | ||
|
|
d5b9836465 | ||
|
|
591fe5daa8 | ||
|
|
99f8f32d66 | ||
|
|
c4db994753 | ||
|
|
9b08945d62 | ||
|
|
7bfd3934f8 | ||
|
|
06e1e12ce8 | ||
|
|
4c347b9df0 | ||
|
|
32dac79565 | ||
|
|
271ddc1e5e | ||
|
|
490a4509a0 | ||
|
|
ad23bb1bf8 | ||
|
|
7e15ccd231 | ||
|
|
5c10caa19a | ||
|
|
ceb51a18df | ||
|
|
c21f217425 | ||
|
|
9299326dc2 | ||
|
|
fbe597706a | ||
|
|
c7b6257c74 | ||
|
|
dbe6f8589d | ||
|
|
9139c8eaf8 | ||
|
|
05451a0f07 | ||
|
|
36d4d8e449 | ||
|
|
fa8551dd31 | ||
|
|
7cbf8eb72a | ||
|
|
e739662d49 | ||
|
|
e26fa35470 | ||
|
|
37e92fd084 | ||
|
|
0aef3efda9 | ||
|
|
7187da853b | ||
|
|
2f9eacdff9 | ||
|
|
b81dba9f8b | ||
|
|
bf0fd62bff | ||
|
|
67e6043cbc | ||
|
|
9d169219ce | ||
|
|
8eb6a0f857 | ||
|
|
9c2d3bd2d8 | ||
|
|
d6de73d7d4 | ||
|
|
8899654937 | ||
|
|
d64cb4116a | ||
|
|
fd7ddc3819 | ||
|
|
f428849daa | ||
|
|
83143f4438 | ||
|
|
8adc6cb7b4 | ||
|
|
d12eccb6aa | ||
|
|
93a1dedd8f | ||
|
|
027a4a947a | ||
|
|
67fd8d3d47 | ||
|
|
e42532ad7c | ||
|
|
944a35a905 | ||
|
|
9f620866cb | ||
|
|
4d74e9c47c | ||
|
|
f1a37deab2 | ||
|
|
f36dd4b964 | ||
|
|
80c842412a | ||
|
|
219ef68001 | ||
|
|
ab8a551a96 | ||
|
|
467382879d | ||
|
|
258f6cd0f0 | ||
|
|
2c3bf1ebbc | ||
|
|
1113b698be | ||
|
|
eefcc6866b | ||
|
|
34185ac8fb | ||
|
|
c1e85ce422 | ||
|
|
3a1529e7f4 | ||
|
|
f4db87bed3 | ||
|
|
e648425505 | ||
|
|
f2c4da4aad | ||
|
|
db268b2966 | ||
|
|
c88024906f | ||
|
|
c7b5bb426c | ||
|
|
00be0781aa | ||
|
|
6ed270112d | ||
|
|
df4c457dd4 | ||
|
|
8a5bc21206 | ||
|
|
a27d41b718 | ||
|
|
2d3a89bd56 | ||
|
|
d39dad7275 | ||
|
|
37107c495f | ||
|
|
2a910ddde4 | ||
|
|
11d7e7d431 | ||
|
|
991de00891 | ||
|
|
7747582e70 | ||
|
|
28550ec84c | ||
|
|
8246b48f59 | ||
|
|
455a70c64c | ||
|
|
f0f797a996 | ||
|
|
037763770d | ||
|
|
8d4299c899 | ||
|
|
534ed126d4 | ||
|
|
0fa6ecd3ce | ||
|
|
7dfb630cb5 | ||
|
|
13e1aacd3b | ||
|
|
9ad5143588 | ||
|
|
9e867d5f4e | ||
|
|
f3b186d525 | ||
|
|
5f9a50804b | ||
|
|
486603aff7 | ||
|
|
feec4b7576 | ||
|
|
f64f7b973e | ||
|
|
31f941e262 | ||
|
|
09d312ee46 | ||
|
|
a92e8f1b1a | ||
|
|
bca66f7c0b | ||
|
|
b743a31610 | ||
|
|
b1dc116cae | ||
|
|
fae57224a8 | ||
|
|
c8ffea31d9 | ||
|
|
fc3b2a4015 | ||
|
|
f70272763f | ||
|
|
a15c2a3ca7 | ||
|
|
550555c0c5 | ||
|
|
6e201a8c29 | ||
|
|
dd139170d1 | ||
|
|
66412327fa | ||
|
|
7736271ba0 | ||
|
|
4236c8495a | ||
|
|
6c930716fc | ||
|
|
522b00612a | ||
|
|
e36ff7bdd6 | ||
|
|
d168731cbd | ||
|
|
715daf499f | ||
|
|
6f5449e4b9 | ||
|
|
7f3690d5b8 | ||
|
|
1046dc9171 | ||
|
|
28f672d989 | ||
|
|
3f5ddfa346 | ||
|
|
f1f4e99dab | ||
|
|
67f3917a8d | ||
|
|
02988fac2c | ||
|
|
d21deb0725 | ||
|
|
2a5d316e58 | ||
|
|
557a01b4d0 | ||
|
|
680dbee6eb | ||
|
|
b525c5efb4 | ||
|
|
347141f93b | ||
|
|
9a6e8b1635 | ||
|
|
8a42d0346b | ||
|
|
0e0a3f17ae | ||
|
|
9298f76a68 | ||
|
|
e85ff79dbe | ||
|
|
7a19b7887a | ||
|
|
08e7efa637 | ||
|
|
cc348c0c96 | ||
|
|
5a059c151f | ||
|
|
4063b71345 | ||
|
|
129ef9ccd8 | ||
|
|
d60e4fcfa4 | ||
|
|
310c6d4c55 | ||
|
|
fffccbe5b5 | ||
|
|
9685f1e952 | ||
|
|
ef53c2dd5f | ||
|
|
7e0f7a32af | ||
|
|
cdea68e754 | ||
|
|
90069e4ef4 | ||
|
|
8dbaa60b58 | ||
|
|
19b38dec4c | ||
|
|
9c246984d1 | ||
|
|
ff0e10c16c | ||
|
|
ec165d4a78 | ||
|
|
afe718ee09 | ||
|
|
4aca01a98d | ||
|
|
140282f1ff | ||
|
|
4d38d19e4f | ||
|
|
5e39417a64 | ||
|
|
03ec7ebcd9 | ||
|
|
fbb6def555 | ||
|
|
ae9e30eb73 | ||
|
|
9e89c6946b | ||
|
|
6ff83d0797 | ||
|
|
5ff131ae29 | ||
|
|
e80f88ef2c | ||
|
|
cff01c151b | ||
|
|
6ca85a41a2 | ||
|
|
1630b493b1 | ||
|
|
518ece3cab | ||
|
|
aba2fd1d35 | ||
|
|
fcd68568c2 | ||
|
|
1b5e9ffe0d | ||
|
|
b3c3e44ba2 | ||
|
|
67b49d3f87 | ||
|
|
0d3e1d31bb | ||
|
|
28a27a1c65 | ||
|
|
8c3ea21ef1 | ||
|
|
417596db36 | ||
|
|
28240162b3 | ||
|
|
6dca357782 | ||
|
|
d1fe06a4e9 | ||
|
|
97cba5681b | ||
|
|
715d2ba62b | ||
|
|
32673c21fb | ||
|
|
f0c47705cb | ||
|
|
612b91e05f | ||
|
|
b4cce42844 | ||
|
|
2c2d57ecab | ||
|
|
d05374ca68 | ||
|
|
b5c02a9aff | ||
|
|
1e3568a1c4 | ||
|
|
bdeebbc9cc | ||
|
|
510e6fd273 | ||
|
|
2ca98bbb10 | ||
|
|
49cff0c22c | ||
|
|
943bf41dc5 | ||
|
|
6c9ba75906 | ||
|
|
70976ee42a | ||
|
|
5848698abf | ||
|
|
29dd1eb21f | ||
|
|
ebe6d3c6e4 | ||
|
|
425bfea265 | ||
|
|
c58efe8d00 | ||
|
|
955d04e532 | ||
|
|
0031a9c6ba | ||
|
|
8fb778337d | ||
|
|
a48d39a863 | ||
|
|
36b2a8f2d7 | ||
|
|
00e9d44ba9 | ||
|
|
62b068a94a | ||
|
|
af926002e9 | ||
|
|
0612f9c44f | ||
|
|
fbf545c2ed | ||
|
|
c7ef97cb4f | ||
|
|
564f48540e | ||
|
|
52e729d212 | ||
|
|
bdfa7606dd | ||
|
|
056e0e8e3a | ||
|
|
879ba258b2 | ||
|
|
3d62d2689f | ||
|
|
3b7a9ca5cd | ||
|
|
ac892d2868 | ||
|
|
19bde7bb2f | ||
|
|
419b1872b8 | ||
|
|
bbeb4ee279 | ||
|
|
f2ca7d9140 | ||
|
|
70b95c6ad1 | ||
|
|
5a3f621093 | ||
|
|
631eb380fc | ||
|
|
cb9778ba15 | ||
|
|
38106a8199 | ||
|
|
226e94857b | ||
|
|
f94c701657 | ||
|
|
259109cc38 | ||
|
|
e120df30e3 | ||
|
|
43f351a943 | ||
|
|
502b8e20d5 | ||
|
|
ff5858f965 | ||
|
|
8b8ef52d98 | ||
|
|
7032bc0726 | ||
|
|
ba65dec596 | ||
|
|
78cf88fbd9 | ||
|
|
93e16d899a | ||
|
|
14060bda94 | ||
|
|
45b729d708 | ||
|
|
9023ea6298 | ||
|
|
d29176cf98 | ||
|
|
55989d8480 | ||
|
|
9c936974c7 | ||
|
|
311b4683d0 | ||
|
|
bf61697154 | ||
|
|
52818f1e34 | ||
|
|
174ea05a92 | ||
|
|
dcb4e06fea | ||
|
|
62eb6a4a01 | ||
|
|
f643f3bd9a | ||
|
|
972370e0e6 | ||
|
|
a6feb72339 | ||
|
|
c751706631 | ||
|
|
8900324234 | ||
|
|
f1b880d898 | ||
|
|
9a285d7470 | ||
|
|
15259560e0 | ||
|
|
d8afa166aa | ||
|
|
d39791257e | ||
|
|
06b7005782 | ||
|
|
bc6c933440 | ||
|
|
b965a89db3 | ||
|
|
9b82e327e2 | ||
|
|
5808125d92 | ||
|
|
f49fe258aa | ||
|
|
08df9e8c33 | ||
|
|
56e388edd8 | ||
|
|
ded75c7af1 | ||
|
|
557c426078 | ||
|
|
d61a83a0a3 | ||
|
|
7500c6c1cb | ||
|
|
b51076dd0a | ||
|
|
8010bfc832 | ||
|
|
0f80f26d17 | ||
|
|
40d25c43f4 | ||
|
|
686a7382ab | ||
|
|
88401bd7b1 | ||
|
|
1d97467c05 | ||
|
|
2388fa148b | ||
|
|
d574fccbc8 | ||
|
|
89cc55a1eb | ||
|
|
8c75354235 | ||
|
|
9eb9dc8cd8 | ||
|
|
afa28bddd3 | ||
|
|
5cf0e76765 | ||
|
|
16b3f8ff49 | ||
|
|
d2055e0f6d | ||
|
|
4010157baf | ||
|
|
75c7e83264 | ||
|
|
8ca22e0dcc | ||
|
|
2b8d1f470c | ||
|
|
c4d9651e95 | ||
|
|
813c0ebd93 | ||
|
|
e376942c8d | ||
|
|
6d91c11856 | ||
|
|
1d47b1fb7b | ||
|
|
54a9e586bf | ||
|
|
b58c2a1356 | ||
|
|
dec5e58063 | ||
|
|
b0a4130229 | ||
|
|
a06e992b8a | ||
|
|
6e1072bea9 | ||
|
|
1f9eb18bfb | ||
|
|
603bd2b107 | ||
|
|
0163d9e4f9 | ||
|
|
910579f463 | ||
|
|
d6a28a312a | ||
|
|
267a4a7be5 | ||
|
|
59111a1a90 | ||
|
|
9f5d1aef74 | ||
|
|
36eed57ec2 | ||
|
|
cc054bb24b | ||
|
|
056256015d | ||
|
|
dd5f3795ec | ||
|
|
8c29d43bef | ||
|
|
aa32258aa1 | ||
|
|
d2394aca77 | ||
|
|
6804535fe4 | ||
|
|
3852e7d96f | ||
|
|
eb852caee8 | ||
|
|
1414693e33 | ||
|
|
fbdb7e77c3 | ||
|
|
c72658938f | ||
|
|
718c582a8c | ||
|
|
12f385dffd | ||
|
|
35f500d218 | ||
|
|
44949709d1 | ||
|
|
476557db80 | ||
|
|
f7625a8250 | ||
|
|
c06c236da5 | ||
|
|
24c262282e | ||
|
|
876330522d | ||
|
|
f1c771e95c | ||
|
|
d3668b25e9 | ||
|
|
b0493c370c | ||
|
|
e67caa0ffe | ||
|
|
82a44ddfef | ||
|
|
205fe6cc23 | ||
|
|
591cac8bfa | ||
|
|
42debdeab0 | ||
|
|
0555452bf2 | ||
|
|
cc7f9d94bb | ||
|
|
51d986b86f | ||
|
|
83057d65cd | ||
|
|
b886f20570 | ||
|
|
dacd2d898b | ||
|
|
a2490efa80 | ||
|
|
e087b79ade | ||
|
|
10b1a8cb07 | ||
|
|
f2c06462fa | ||
|
|
ad7ba44a2e | ||
|
|
7a72c96e79 | ||
|
|
d955044a3b | ||
|
|
b86fafd538 | ||
|
|
c52a4e6638 | ||
|
|
9d66da6bf9 | ||
|
|
4b76d0b7aa | ||
|
|
626026eebc | ||
|
|
fdd12acb8e | ||
|
|
02015826ff | ||
|
|
5d07ce32d8 | ||
|
|
3967b16d25 | ||
|
|
f325183691 | ||
|
|
a029850531 | ||
|
|
d3d2ae5889 | ||
|
|
4a049b2a7a | ||
|
|
fd349f2da8 | ||
|
|
f338bd5ecb | ||
|
|
a3470cd844 | ||
|
|
f4635d794f | ||
|
|
426fe44dc8 | ||
|
|
4ed39cec1a | ||
|
|
b1f0844546 | ||
|
|
88aa7e4024 | ||
|
|
471f6348f1 | ||
|
|
9dfb70c941 | ||
|
|
6fa7e0d1c0 | ||
|
|
5ccfa654ec | ||
|
|
1c67068eab | ||
|
|
f5ff2a0823 | ||
|
|
58503f67f3 | ||
|
|
5dd0479caf | ||
|
|
7441f1a203 | ||
|
|
4d3ebb0ac6 | ||
|
|
47d5716238 | ||
|
|
94d888a61e | ||
|
|
ddd43a1514 | ||
|
|
2cf94f981b | ||
|
|
f1973f63c2 | ||
|
|
dfb67e17d5 | ||
|
|
48239772f6 | ||
|
|
1cea9d0e13 | ||
|
|
6591c36ef4 | ||
|
|
41de9c47b0 | ||
|
|
0ba1a8a1d1 | ||
|
|
6fcb153244 | ||
|
|
7a8d97b1cd | ||
|
|
9e37076d0d | ||
|
|
f8e5661353 | ||
|
|
4d4cda6cac | ||
|
|
8b528f39f2 | ||
|
|
e1a04b4a20 | ||
|
|
f0e8ffe565 | ||
|
|
f9c1e64235 | ||
|
|
0174c9156b | ||
|
|
2ee81e6ff3 | ||
|
|
42ab5a3e9e | ||
|
|
e9fa663410 | ||
|
|
2d17a267be | ||
|
|
40371cdded | ||
|
|
6bb315a2fc | ||
|
|
fc2c9a1439 | ||
|
|
b50935ecd6 | ||
|
|
9b73d60c5d | ||
|
|
504ee8cf5e | ||
|
|
057bbddd9f | ||
|
|
4063979c2a | ||
|
|
da391fe9ed | ||
|
|
953ca9257c | ||
|
|
396bedd319 | ||
|
|
b6c8260faf | ||
|
|
e05269f117 | ||
|
|
77c7ca8835 | ||
|
|
e877f5c610 | ||
|
|
42e1a79697 | ||
|
|
ce32cdc8a4 | ||
|
|
e2a0bd23c0 | ||
|
|
98f0ebde8b | ||
|
|
bc018f8b39 | ||
|
|
03bd478aaa | ||
|
|
67a8582c22 | ||
|
|
7533575df9 | ||
|
|
34fcd74d93 | ||
|
|
1558feb734 | ||
|
|
bc4d9f9e2f | ||
|
|
4142c7b51e | ||
|
|
e36a749223 | ||
|
|
e69d9b4238 | ||
|
|
3132a39816 | ||
|
|
03f9fa51ff | ||
|
|
e2062aefe9 | ||
|
|
855ba7bbfb | ||
|
|
ad71b6398d | ||
|
|
0ea0629104 | ||
|
|
8b2f12fcdd | ||
|
|
e66bff74bf | ||
|
|
58ea34bb49 | ||
|
|
275c2c3c88 | ||
|
|
f13c29adad | ||
|
|
ec9f836a1f | ||
|
|
589d157be5 | ||
|
|
a2719bc771 | ||
|
|
c5645299aa | ||
|
|
6aac3f296c | ||
|
|
137635f11a | ||
|
|
03f69c02c1 | ||
|
|
8487d2f443 | ||
|
|
a7bee1fea7 | ||
|
|
43292de507 | ||
|
|
bee30defb5 | ||
|
|
52649a2d3c | ||
|
|
be47ac6573 | ||
|
|
24ce27090e | ||
|
|
8dd0225720 |
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
PUBLIC_API_URL=http://api.roadmap.sh
|
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
||||
25
.github/ISSUE_TEMPLATE/01-suggest-changes.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: "✍️ Suggest Changes"
|
||||
description: Help us improve the roadmaps by suggesting changes
|
||||
labels: [suggestion]
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to help us improve the roadmaps with your suggestions.
|
||||
- type: input
|
||||
id: url
|
||||
attributes:
|
||||
label: Roadmap URL
|
||||
description: Please provide the URL of the roadmap you are suggesting changes to.
|
||||
placeholder: https://roadmap.sh
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: roadmap-suggestions
|
||||
attributes:
|
||||
label: Suggestions
|
||||
description: What changes would you like to suggest?
|
||||
placeholder: Enter your suggestions here.
|
||||
validations:
|
||||
required: true
|
||||
42
.github/ISSUE_TEMPLATE/02-bug-report.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: "🐛 Bug Report"
|
||||
description: Report an issue or possible bug
|
||||
labels: [bug]
|
||||
assignees: []
|
||||
body:
|
||||
- type: input
|
||||
id: url
|
||||
attributes:
|
||||
label: What is the URL where the issue is happening
|
||||
placeholder: https://roadmap.sh
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What browsers are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- Firefox
|
||||
- Chrome
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
- Other
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Describe the Bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Output from browser console (if any)
|
||||
description: Please copy and paste any relevant log output.
|
||||
- type: checkboxes
|
||||
id: will-pr
|
||||
attributes:
|
||||
label: Participation
|
||||
options:
|
||||
- label: I am willing to submit a pull request for this issue.
|
||||
required: false
|
||||
12
.github/ISSUE_TEMPLATE/03-feature-suggestion.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: "✨ Feature Suggestion"
|
||||
description: Is there a feature you'd like to see on Roadmap.sh? Let us know!
|
||||
labels: [feature request]
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: Please provide a detailed description of the feature you are suggesting and how it would help you/others.
|
||||
validations:
|
||||
required: true
|
||||
37
.github/ISSUE_TEMPLATE/04-roadmap-contribution.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: "🙏 Submit a Roadmap"
|
||||
description: Help us launch a new roadmap with your expertise.
|
||||
labels: [roadmap contribution]
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to submit a roadmap! Please fill out the information below and we'll get back to you as soon as we can.
|
||||
- type: input
|
||||
id: roadmap-title
|
||||
attributes:
|
||||
label: What is the title of the roadmap you are submitting?
|
||||
placeholder: e.g. Roadmap to learn Data Science
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: Is this roadmap prepared by you or someone else?
|
||||
options:
|
||||
- I prepared this roadmap
|
||||
- I found this roadmap online (please provide a link below)
|
||||
- type: textarea
|
||||
id: roadmap-description
|
||||
attributes:
|
||||
label: Roadmap Items
|
||||
description: Please submit a nested list of items which we can convert into the visual. Here is an [example of roadmap items list.](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5).
|
||||
placeholder: |
|
||||
- Item 1
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
- Item 2
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
validations:
|
||||
required: true
|
||||
12
.github/ISSUE_TEMPLATE/05-something-else.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: "🤷♂️ Something else"
|
||||
description: If none of the above templates fit your needs, please use this template to submit your issue.
|
||||
labels: []
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: issue-description
|
||||
attributes:
|
||||
label: Detailed Description
|
||||
description: Please provide a detailed description of the issue.
|
||||
validations:
|
||||
required: true
|
||||
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ✋ Roadmap Request
|
||||
url: https://discord.gg/cJpEt5Qbwa
|
||||
about: Please do not open issues with roadmap requests, hop onto the discord server for that.
|
||||
- name: 📝 Typo or Grammatical Mistake
|
||||
url: https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data
|
||||
about: Please submit a pull request instead of reporting it as an issue.
|
||||
- name: 💬 Chat on Discord
|
||||
url: https://discord.gg/cJpEt5Qbwa
|
||||
about: Join the community on our Discord server.
|
||||
- name: 🤝 Guidance
|
||||
url: https://discord.gg/cJpEt5Qbwa
|
||||
about: Join the community in our Discord server.
|
||||
2
.github/workflows/deploy.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
env:
|
||||
PUBLIC_API_URL: "https://api.roadmap.sh"
|
||||
PUBLIC_AVATAR_BASE_URL: "https://dodrc8eu8m09s.cloudfront.net/avatars"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PAT: ${{ secrets.PAT }}
|
||||
CI: true
|
||||
|
||||
43
.github/workflows/update-sponsors.yml
vendored
@@ -1,43 +0,0 @@
|
||||
name: Update Sponsors
|
||||
|
||||
on:
|
||||
workflow_dispatch: # allow manual run
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # run daily at 00:00 UTC
|
||||
|
||||
env:
|
||||
SPONSOR_SHEET_API_KEY: ${{ secrets.SPONSOR_SHEET_API_KEY }}
|
||||
SPONSOR_SHEET_ID: ${{ secrets.SPONSOR_SHEET_ID }}
|
||||
|
||||
jobs:
|
||||
update-sponsors:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7.13.4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Update sponsors
|
||||
run: |
|
||||
node bin/update-sponsors.cjs
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
delete-branch: false
|
||||
branch: 'update-sponsors'
|
||||
base: 'master'
|
||||
labels: |
|
||||
sponsors
|
||||
automated pr
|
||||
reviewers: kamranahmedse
|
||||
commit-message: 'chore: update sponsors'
|
||||
title: 'Update Sponsor Banners'
|
||||
body: |
|
||||
Updates sponsor banners.
|
||||
Please review the changes and merge if everything looks good.
|
||||
7
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
.idea
|
||||
|
||||
# build output
|
||||
dist/
|
||||
.output/
|
||||
@@ -5,7 +7,7 @@ dist/
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
bin/developer-roadmap
|
||||
scripts/developer-roadmap
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
@@ -23,4 +25,5 @@ pnpm-debug.log*
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
tests-examples
|
||||
tests-examples
|
||||
*.csv
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// https://astro.build/config
|
||||
import preact from '@astrojs/preact';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import compress from 'astro-compress';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import rehypeExternalLinks from 'rehype-external-links';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://roadmap.sh/',
|
||||
markdown: {
|
||||
@@ -43,6 +46,22 @@ export default defineConfig({
|
||||
format: 'file',
|
||||
},
|
||||
integrations: [
|
||||
{
|
||||
name: 'client-authenticated',
|
||||
hooks: {
|
||||
'astro:config:setup'(options) {
|
||||
options.addClientDirective({
|
||||
name: 'authenticated',
|
||||
entrypoint: fileURLToPath(
|
||||
new URL(
|
||||
'./src/directives/client-authenticated.mjs',
|
||||
import.meta.url
|
||||
)
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tailwind({
|
||||
config: {
|
||||
applyBaseStyles: false,
|
||||
@@ -56,5 +75,6 @@ export default defineConfig({
|
||||
css: false,
|
||||
js: false,
|
||||
}),
|
||||
preact(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const jsonsDir = path.join(process.cwd(), 'public/jsons');
|
||||
const childJsonDirs = fs.readdirSync(jsonsDir);
|
||||
|
||||
childJsonDirs.forEach((childJsonDir) => {
|
||||
const fullChildJsonDirPath = path.join(jsonsDir, childJsonDir);
|
||||
const jsonFiles = fs.readdirSync(fullChildJsonDirPath);
|
||||
|
||||
jsonFiles.forEach((jsonFileName) => {
|
||||
console.log(`Compressing ${jsonFileName}...`);
|
||||
|
||||
const jsonFilePath = path.join(fullChildJsonDirPath, jsonFileName);
|
||||
const json = require(jsonFilePath);
|
||||
|
||||
fs.writeFileSync(jsonFilePath, JSON.stringify(json));
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ For new roadmaps, submit a roadmap by providing [a textual roadmap similar to th
|
||||
|
||||
For the existing roadmaps, please follow the details listed for the nature of contribution:
|
||||
|
||||
- **Fixing Typos** — Make your changes in the [roadmap JSON file](https://github.com/kamranahmedse/developer-roadmap/tree/master/public/jsons)
|
||||
- **Fixing Typos** — Make your changes in the [roadmap JSON file](https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/roadmaps)
|
||||
- **Adding or Removing Nodes** — Please open an issue with your suggestion.
|
||||
|
||||
**Note:** Please note that our goal is not to have the biggest list of items. Our goal is to list items or skills most relevant today.
|
||||
|
||||
47
package.json
@@ -11,34 +11,45 @@
|
||||
"format": "prettier --write .",
|
||||
"astro": "astro",
|
||||
"deploy": "NODE_DEBUG=gh-pages gh-pages -d dist -t",
|
||||
"compress:jsons": "node bin/compress-jsons.cjs",
|
||||
"compress:jsons": "node scripts/compress-jsons.cjs",
|
||||
"upgrade": "ncu -u",
|
||||
"roadmap-links": "node bin/roadmap-links.cjs",
|
||||
"roadmap-dirs": "node bin/roadmap-dirs.cjs",
|
||||
"roadmap-content": "node bin/roadmap-content.cjs",
|
||||
"best-practice-dirs": "node bin/best-practice-dirs.cjs",
|
||||
"roadmap-links": "node scripts/roadmap-links.cjs",
|
||||
"roadmap-dirs": "node scripts/roadmap-dirs.cjs",
|
||||
"roadmap-content": "node scripts/roadmap-content.cjs",
|
||||
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
|
||||
"best-practice-content": "node scripts/best-practice-content.cjs",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "^1.2.1",
|
||||
"@astrojs/tailwind": "^3.1.1",
|
||||
"astro": "^2.1.7",
|
||||
"astro-compress": "^1.1.35",
|
||||
"@astrojs/preact": "^2.2.1",
|
||||
"@astrojs/sitemap": "^1.3.3",
|
||||
"@astrojs/tailwind": "^3.1.3",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.1",
|
||||
"@nanostores/preact": "^0.5.0",
|
||||
"astro": "^2.6.6",
|
||||
"astro-compress": "^1.1.47",
|
||||
"jose": "^4.14.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nanostores": "^0.9.2",
|
||||
"node-html-parser": "^6.1.5",
|
||||
"npm-check-updates": "^16.8.0",
|
||||
"rehype-external-links": "^2.0.1",
|
||||
"roadmap-renderer": "^1.0.4",
|
||||
"tailwindcss": "^3.2.7"
|
||||
"npm-check-updates": "^16.10.12",
|
||||
"preact": "^10.15.1",
|
||||
"rehype-external-links": "^2.1.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"slugify": "^1.6.6",
|
||||
"tailwindcss": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.32.1",
|
||||
"@playwright/test": "^1.35.1",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^5.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"markdown-it": "^13.0.1",
|
||||
"openai": "^3.2.1",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-astro": "^0.8.0",
|
||||
"prettier-plugin-tailwindcss": "^0.2.6"
|
||||
"openai": "^3.3.0",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-astro": "^0.10.0",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
8213
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
@@ -1,13 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 36 36">
|
||||
<path fill="#000" d="M22.25 4h-8.5a1 1 0 0 0-.96.73l-5.54 19.4a.5.5 0 0 0 .62.62l5.05-1.44a2 2 0 0 0 1.38-1.4l3.22-11.66a.5.5 0 0 1 .96 0l3.22 11.67a2 2 0 0 0 1.38 1.39l5.05 1.44a.5.5 0 0 0 .62-.62l-5.54-19.4a1 1 0 0 0-.96-.73Z"/>
|
||||
<path fill="url(#gradient)" d="M18 28a7.63 7.63 0 0 1-5-2c-1.4 2.1-.35 4.35.6 5.55.14.17.41.07.47-.15.44-1.8 2.93-1.22 2.93.6 0 2.28.87 3.4 1.72 3.81.34.16.59-.2.49-.56-.31-1.05-.29-2.46 1.29-3.25 3-1.5 3.17-4.83 2.5-6-.67.67-2.6 2-5 2Z"/>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="16" x2="16" y1="32" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#000"/>
|
||||
<stop offset="1" stop-color="#000" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style>
|
||||
@media (prefers-color-scheme:dark){:root{filter:invert(100%)}}
|
||||
</style>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 873 B |
BIN
public/guides/llms.png
Normal file
|
After Width: | Height: | Size: 691 KiB |
BIN
public/images/default-avatar.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/images/features/in-progress.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/images/partners/ambassador-graphic-1.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/images/partners/ambassador-graphic-2.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 101 KiB |
BIN
public/images/partners/apollo-event.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/images/partners/apollo-learning.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/images/partners/apollo-workshop.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
BIN
public/images/partners/graphql-summit.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/images/partners/honeycomb-ebook.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 62 KiB |
BIN
public/og-images/sql-roadmap.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
public/pdfs/best-practices/code-review.pdf
Normal file
BIN
public/pdfs/roadmaps/android.pdf
Normal file
BIN
public/pdfs/roadmaps/code-review.pdf
Normal file
BIN
public/pdfs/roadmaps/cpp.pdf
Normal file
BIN
public/pdfs/roadmaps/docker.pdf
Normal file
BIN
public/pdfs/roadmaps/full-stack.pdf
Normal file
BIN
public/pdfs/roadmaps/postgresql-dba.pdf
Normal file
BIN
public/pdfs/roadmaps/prompt-engineering.pdf
Normal file
BIN
public/pdfs/roadmaps/react-native.pdf
Normal file
BIN
public/pdfs/roadmaps/sql.pdf
Normal file
BIN
public/roadmaps/cpp.png
Normal file
|
After Width: | Height: | Size: 773 KiB |
BIN
public/roadmaps/docker.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
public/roadmaps/full-stack.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
public/roadmaps/sql.png
Normal file
|
After Width: | Height: | Size: 550 KiB |
31
readme.md
@@ -4,16 +4,16 @@
|
||||
<p align="center">Community driven roadmaps, articles and resources for developers<p>
|
||||
<p align="center">
|
||||
<a href="https://roadmap.sh/roadmaps">
|
||||
<img src="https://img.shields.io/badge/-Roadmaps%20-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Roadmaps%20-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
</a>
|
||||
<a href="https://roadmap.sh/best-practices">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Best%20Practices-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="best practices" />
|
||||
</a>
|
||||
<a href="https://youtube.com/theroadmap?sub_confirmation=1">
|
||||
<img src="https://img.shields.io/badge/-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
</a>
|
||||
<a href="https://github.com/kamranahmedse/developer-roadmap/tree/0471d44c8fae58b6a36a7c57bba12253916d0249/translations">
|
||||
<img src="https://img.shields.io/badge/-Translations-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
</a>
|
||||
<a href="https://www.youtube.com/channel/UCA0H2KIWgWTwpTFjSxp0now?sub_confirmation=1">
|
||||
<img src="https://img.shields.io/badge/%E2%9D%A4-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
@@ -30,16 +30,19 @@ Roadmaps are now interactive, you can click the nodes to read more about the top
|
||||
|
||||
Here is the list of available roadmaps with more being actively worked upon.
|
||||
|
||||
- [Frontend Roadmap](https://roadmap.sh/frontend)
|
||||
- [Frontend Roadmap](https://roadmap.sh/frontend) / [Frontend Beginner Roadmap](https://roadmap.sh/frontend?r=frontend-beginner)
|
||||
- [Backend Roadmap](https://roadmap.sh/backend)
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops)
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
|
||||
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
|
||||
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
|
||||
- [QA Roadmap](https://roadmap.sh/qa)
|
||||
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
|
||||
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
|
||||
- [JavaScript Roadmap](https://roadmap.sh/javascript)
|
||||
- [TypeScript Roadmap](https://roadmap.sh/typescript)
|
||||
- [C++ Roadmap](https://roadmap.sh/cpp)
|
||||
- [React Roadmap](https://roadmap.sh/react)
|
||||
- [React Native Roadmap](https://roadmap.sh/react-native)
|
||||
- [Vue Roadmap](https://roadmap.sh/vue)
|
||||
- [Angular Roadmap](https://roadmap.sh/angular)
|
||||
- [Node.js Roadmap](https://roadmap.sh/nodejs)
|
||||
@@ -51,7 +54,8 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [Java Roadmap](https://roadmap.sh/java)
|
||||
- [Spring Boot Roadmap](https://roadmap.sh/spring-boot)
|
||||
- [Design System Roadmap](https://roadmap.sh/design-system)
|
||||
- [DBA Roadmap](https://roadmap.sh/postgresql-dba)
|
||||
- [PostgreSQL Roadmap](https://roadmap.sh/postgresql-dba)
|
||||
- [SQL Roadmap](https://roadmap.sh/sql)
|
||||
- [Blockchain Roadmap](https://roadmap.sh/blockchain)
|
||||
- [ASP.NET Core Roadmap](https://roadmap.sh/aspnet-core)
|
||||
- [System Design Roadmap](https://roadmap.sh/system-design)
|
||||
@@ -59,9 +63,12 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [Cyber Security Roadmap](https://roadmap.sh/cyber-security)
|
||||
- [MongoDB Roadmap](https://roadmap.sh/mongodb)
|
||||
- [UX Design Roadmap](https://roadmap.sh/ux-design)
|
||||
- [Docker Roadmap](https://roadmap.sh/docker)
|
||||
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
|
||||
|
||||
We have also added a new form of visual content covering best practices:
|
||||
|
||||
- [Code Review Best Practices](https://roadmap.sh/best-practices/code-review)
|
||||
- [Frontend Performance Best Practices](https://roadmap.sh/best-practices/frontend-performance)
|
||||
- [API Security Best Practices](https://roadmap.sh/best-practices/api-security)
|
||||
- [AWS Best Practices](https://roadmap.sh/best-practices/aws)
|
||||
@@ -88,6 +95,12 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Note: use the `depth` parameter to reduce the clone size and speed up the clone.
|
||||
|
||||
```sh
|
||||
git clone --depth=1 https://github.com/kamranahmedse/developer-roadmap.git
|
||||
```
|
||||
|
||||
## Contribution
|
||||
|
||||
> Have a look at [contribution docs](./contributing.md) for how to update any of the roadmaps
|
||||
|
||||
173
scripts/best-practice-content.cjs
Normal file
@@ -0,0 +1,173 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY;
|
||||
const ALL_BEST_PRACTICES_DIR = path.join(
|
||||
__dirname,
|
||||
'../src/data/best-practices'
|
||||
);
|
||||
const BEST_PRACTICE_JSON_DIR = path.join(
|
||||
__dirname,
|
||||
'../public/jsons/best-practices'
|
||||
);
|
||||
|
||||
const bestPracticeId = process.argv[2];
|
||||
const bestPracticeTitle = bestPracticeId.replace(/-/g, ' ');
|
||||
|
||||
const allowedBestPracticeIds = fs.readdirSync(ALL_BEST_PRACTICES_DIR);
|
||||
if (!bestPracticeId) {
|
||||
console.error('bestPracticeId is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!allowedBestPracticeIds.includes(bestPracticeId)) {
|
||||
console.error(`Invalid bestPractice key ${bestPracticeId}`);
|
||||
console.error(`Allowed keys are ${allowedBestPracticeIds.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BEST_PRACTICE_CONTENT_DIR = path.join(
|
||||
ALL_BEST_PRACTICES_DIR,
|
||||
bestPracticeId,
|
||||
'content'
|
||||
);
|
||||
const { Configuration, OpenAIApi } = require('openai');
|
||||
const configuration = new Configuration({
|
||||
apiKey: OPEN_AI_API_KEY,
|
||||
});
|
||||
|
||||
const openai = new OpenAIApi(configuration);
|
||||
|
||||
function getFilesInFolder(folderPath, fileList = {}) {
|
||||
const files = fs.readdirSync(folderPath);
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(folderPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
getFilesInFolder(filePath, fileList);
|
||||
} else if (stats.isFile()) {
|
||||
const fileUrl = filePath
|
||||
.replace(BEST_PRACTICE_CONTENT_DIR, '') // Remove the content folder
|
||||
.replace(/\/\d+-/g, '/') // Remove ordering info `/101-ecosystem`
|
||||
.replace(/\/index\.md$/, '') // Make the `/index.md` to become the parent folder only
|
||||
.replace(/\.md$/, ''); // Remove `.md` from the end of file
|
||||
|
||||
fileList[fileUrl] = filePath;
|
||||
}
|
||||
});
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
function writeTopicContent(topicTitle) {
|
||||
let prompt = `I am reading a guide that has best practices about "${bestPracticeTitle}". I want to know more about "${topicTitle}". Write me a brief introductory paragraph about this and some tips on how I make sure of this? Behave as if you are the author of the guide.`;
|
||||
|
||||
console.log(`Generating '${topicTitle}'...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
openai
|
||||
.createChatCompletion({
|
||||
model: 'gpt-4',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((response) => {
|
||||
const article = response.data.choices[0].message.content;
|
||||
|
||||
resolve(article);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function writeFileForGroup(group, topicUrlToPathMapping) {
|
||||
const topicId = group?.properties?.controlName;
|
||||
const topicTitle = group?.children?.controls?.control?.find(
|
||||
(control) => control?.typeID === 'Label'
|
||||
)?.properties?.text;
|
||||
const currTopicUrl = `/${topicId}`;
|
||||
if (currTopicUrl.startsWith('/check:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentFilePath = topicUrlToPathMapping[currTopicUrl];
|
||||
|
||||
if (!contentFilePath) {
|
||||
console.log(`Missing file for: ${currTopicUrl}`);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFileContent = fs.readFileSync(contentFilePath, 'utf8');
|
||||
const isFileEmpty = currentFileContent.replace(/^#.+/, ``).trim() === '';
|
||||
|
||||
if (!isFileEmpty) {
|
||||
console.log(`Ignoring ${topicId}. Not empty.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let newFileContent = `# ${topicTitle}`;
|
||||
|
||||
if (!OPEN_AI_API_KEY) {
|
||||
console.log(`Writing ${topicId}..`);
|
||||
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
return;
|
||||
}
|
||||
|
||||
const topicContent = await writeTopicContent(topicTitle);
|
||||
newFileContent += `\n\n${topicContent}`;
|
||||
|
||||
console.log(`Writing ${topicId}..`);
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
|
||||
// console.log(currentFileContent);
|
||||
// console.log(currTopicUrl);
|
||||
// console.log(topicTitle);
|
||||
// console.log(topicUrlToPathMapping[currTopicUrl]);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const topicUrlToPathMapping = getFilesInFolder(BEST_PRACTICE_CONTENT_DIR);
|
||||
|
||||
const bestPracticeJson = require(path.join(
|
||||
BEST_PRACTICE_JSON_DIR,
|
||||
`${bestPracticeId}.json`
|
||||
));
|
||||
const groups = bestPracticeJson?.mockup?.controls?.control?.filter(
|
||||
(control) =>
|
||||
control.typeID === '__group__' &&
|
||||
!control.properties?.controlName?.startsWith('ext_link')
|
||||
);
|
||||
|
||||
if (!OPEN_AI_API_KEY) {
|
||||
console.log('----------------------------------------');
|
||||
console.log('OPEN_AI_API_KEY not found. Skipping openai api calls...');
|
||||
console.log('----------------------------------------');
|
||||
}
|
||||
|
||||
const writePromises = [];
|
||||
for (let group of groups) {
|
||||
writePromises.push(writeFileForGroup(group, topicUrlToPathMapping));
|
||||
}
|
||||
|
||||
console.log('Waiting for all files to be written...');
|
||||
await Promise.all(writePromises);
|
||||
}
|
||||
|
||||
run()
|
||||
.then(() => {
|
||||
console.log('Done');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
129
scripts/page-data-agg.cjs
Normal file
@@ -0,0 +1,129 @@
|
||||
const csv = require('csv-parser');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const csvFilePath = path.join(__dirname, '../data.csv');
|
||||
|
||||
const results = {};
|
||||
const pageSummary = {};
|
||||
|
||||
fs.createReadStream(csvFilePath)
|
||||
.pipe(
|
||||
csv({
|
||||
separator: ',',
|
||||
mapHeaders: ({ header, index }) =>
|
||||
header.toLowerCase().replace(/ /g, '_'),
|
||||
mapValues: ({ header, index, value }) => {
|
||||
if (header === 'page') {
|
||||
return (
|
||||
value
|
||||
.replace(/"/g, '')
|
||||
.replace(/'/g, '')
|
||||
.replace(/`/g, '')
|
||||
.replace(/\?r=/g, '#r#')
|
||||
.replace(/\?.+?$/g, '')
|
||||
.replace(/#r#/g, '?r=')
|
||||
.replace(/\/$/g, '') || '/'
|
||||
);
|
||||
}
|
||||
|
||||
if (header !== 'month_of_year') {
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
})
|
||||
)
|
||||
.on('data', (data) => {
|
||||
const { page, month_of_year, unique_pageviews, users } = data;
|
||||
const pageData = results[page] || {};
|
||||
const existingPageMonthData = pageData[month_of_year] || {};
|
||||
|
||||
const existingViews = existingPageMonthData.views || 0;
|
||||
const existingUsers = existingPageMonthData.users || 0;
|
||||
|
||||
const newViews = existingViews + unique_pageviews;
|
||||
const newUsers = existingUsers + users;
|
||||
|
||||
pageData[month_of_year] = {
|
||||
views: newViews,
|
||||
users: newUsers,
|
||||
};
|
||||
|
||||
results[page] = pageData;
|
||||
|
||||
pageSummary[page] = pageSummary[page] || { views: 0, users: 0 };
|
||||
pageSummary[page].views += unique_pageviews;
|
||||
pageSummary[page].users += users;
|
||||
})
|
||||
.on('end', () => {
|
||||
const csvHeader = [
|
||||
'Page',
|
||||
'Jan 2022',
|
||||
'Feb 2022',
|
||||
'Mar 2022',
|
||||
'Apr 2022',
|
||||
'May 2022',
|
||||
'Jun 2022',
|
||||
'Jul 2022',
|
||||
'Aug 2022',
|
||||
'Sep 2022',
|
||||
'Oct 2022',
|
||||
'Nov 2022',
|
||||
'Dec 2022',
|
||||
'Jan 2023',
|
||||
'Feb 2023',
|
||||
'Mar 2023',
|
||||
'Apr 2023',
|
||||
'May 2023',
|
||||
'Jun 2023',
|
||||
'Jul 2023',
|
||||
'Aug 2023',
|
||||
'Sep 2023',
|
||||
'Oct 2023',
|
||||
'Nov 2023',
|
||||
'Dec 2023',
|
||||
];
|
||||
|
||||
const csvRows = Object.keys(pageSummary)
|
||||
.filter(pageUrl => pageSummary[pageUrl].views > 10)
|
||||
.filter(pageUrl => !['/upcoming', '/pdfs', '/signup', '/login', '/@'].includes(pageUrl))
|
||||
.sort((pageA, pageB) => {
|
||||
const aViews = pageSummary[pageA].views;
|
||||
const bViews = pageSummary[pageB].views;
|
||||
|
||||
return bViews - aViews;
|
||||
})
|
||||
.map((pageUrl) => {
|
||||
const rawPageResult = results[pageUrl];
|
||||
const pageResultCsvRow = [];
|
||||
|
||||
csvHeader.forEach((csvHeaderItem) => {
|
||||
if (csvHeaderItem === 'Page') {
|
||||
pageResultCsvRow.push(pageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const csvHeaderItemAlt = csvHeaderItem
|
||||
.replace(/ /g, '_')
|
||||
.toLowerCase();
|
||||
|
||||
const result = rawPageResult[csvHeaderItem || csvHeaderItemAlt] || {};
|
||||
const views = result.views || 0;
|
||||
const users = result.users || 0;
|
||||
|
||||
pageResultCsvRow.push(users);
|
||||
});
|
||||
|
||||
return pageResultCsvRow;
|
||||
});
|
||||
|
||||
const finalCsvRows = [csvHeader, ...csvRows];
|
||||
const csvRowStrings = finalCsvRows.map((row) => {
|
||||
return row.join(',');
|
||||
});
|
||||
|
||||
const csvString = csvRowStrings.join('\n');
|
||||
fs.writeFileSync(path.join(__dirname, '../data-agg.csv'), csvString);
|
||||
});
|
||||
@@ -3,7 +3,6 @@ const path = require('path');
|
||||
|
||||
const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY;
|
||||
const ALL_ROADMAPS_DIR = path.join(__dirname, '../src/data/roadmaps');
|
||||
const ROADMAP_JSON_DIR = path.join(__dirname, '../public/jsons/roadmaps');
|
||||
|
||||
const roadmapId = process.argv[2];
|
||||
|
||||
@@ -61,12 +60,12 @@ function writeTopicContent(currTopicUrl) {
|
||||
|
||||
const roadmapTitle = roadmapId.replace(/-/g, ' ');
|
||||
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief summary for that topic. Content should be in markdown. Behave as if you are the author of the guide.`;
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
|
||||
if (!childTopic) {
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief summary for that topic. Content should be in markdown. Behave as if you are the author of the guide.`;
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
|
||||
}
|
||||
|
||||
console.log(`Genearting '${childTopic || parentTopic}'...`);
|
||||
console.log(`Generating '${childTopic || parentTopic}'...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
openai
|
||||
@@ -90,10 +89,60 @@ function writeTopicContent(currTopicUrl) {
|
||||
});
|
||||
}
|
||||
|
||||
async function writeFileForGroup(group, topicUrlToPathMapping) {
|
||||
const topicId = group?.properties?.controlName;
|
||||
const topicTitle = group?.children?.controls?.control?.find(
|
||||
(control) => control?.typeID === 'Label'
|
||||
)?.properties?.text;
|
||||
const currTopicUrl = topicId?.replace(/^\d+-/g, '/')?.replace(/:/g, '/');
|
||||
if (!currTopicUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentFilePath = topicUrlToPathMapping[currTopicUrl];
|
||||
|
||||
if (!contentFilePath) {
|
||||
console.log(`Missing file for: ${currTopicUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFileContent = fs.readFileSync(contentFilePath, 'utf8');
|
||||
const isFileEmpty = currentFileContent.replace(/^#.+/, ``).trim() === '';
|
||||
|
||||
if (!isFileEmpty) {
|
||||
console.log(`Ignoring ${topicId}. Not empty.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let newFileContent = `# ${topicTitle}`;
|
||||
|
||||
if (!OPEN_AI_API_KEY) {
|
||||
console.log(`Writing ${topicId}..`);
|
||||
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
return;
|
||||
}
|
||||
|
||||
const topicContent = await writeTopicContent(currTopicUrl);
|
||||
newFileContent += `\n\n${topicContent}`;
|
||||
|
||||
console.log(`Writing ${topicId}..`);
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
|
||||
// console.log(currentFileContent);
|
||||
// console.log(currTopicUrl);
|
||||
// console.log(topicTitle);
|
||||
// console.log(topicUrlToPathMapping[currTopicUrl]);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const topicUrlToPathMapping = getFilesInFolder(ROADMAP_CONTENT_DIR);
|
||||
|
||||
const roadmapJson = require(path.join(ROADMAP_JSON_DIR, `${roadmapId}.json`));
|
||||
const roadmapJson = require(path.join(
|
||||
ALL_ROADMAPS_DIR,
|
||||
`${roadmapId}/${roadmapId}`
|
||||
));
|
||||
|
||||
const groups = roadmapJson?.mockup?.controls?.control?.filter(
|
||||
(control) =>
|
||||
control.typeID === '__group__' &&
|
||||
@@ -106,50 +155,13 @@ async function run() {
|
||||
console.log('----------------------------------------');
|
||||
}
|
||||
|
||||
const writePromises = [];
|
||||
for (let group of groups) {
|
||||
const topicId = group?.properties?.controlName;
|
||||
const topicTitle = group?.children?.controls?.control?.find(
|
||||
(control) => control?.typeID === 'Label'
|
||||
)?.properties?.text;
|
||||
const currTopicUrl = topicId?.replace(/^\d+-/g, '/')?.replace(/:/g, '/');
|
||||
if (!currTopicUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentFilePath = topicUrlToPathMapping[currTopicUrl];
|
||||
|
||||
if (!contentFilePath) {
|
||||
console.log(`Missing file for: ${currTopicUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFileContent = fs.readFileSync(contentFilePath, 'utf8');
|
||||
const isFileEmpty = currentFileContent.replace(/^#.+/, ``).trim() === '';
|
||||
|
||||
if (!isFileEmpty) {
|
||||
console.log(`Ignoring ${topicId}. Not empty.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let newFileContent = `# ${topicTitle}`;
|
||||
|
||||
if (!OPEN_AI_API_KEY) {
|
||||
console.log(`Writing ${topicId}..`);
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
continue;
|
||||
}
|
||||
|
||||
const topicContent = await writeTopicContent(currTopicUrl);
|
||||
newFileContent += `\n\n${topicContent}`;
|
||||
|
||||
console.log(`Writing ${topicId}..`);
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
|
||||
// console.log(currentFileContent);
|
||||
// console.log(currTopicUrl);
|
||||
// console.log(topicTitle);
|
||||
// console.log(topicUrlToPathMapping[currTopicUrl]);
|
||||
writePromises.push(writeFileForGroup(group, topicUrlToPathMapping));
|
||||
}
|
||||
|
||||
console.log('Waiting for all files to be written...');
|
||||
await Promise.all(writePromises);
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -84,8 +84,9 @@ function prepareDirTree(control, dirTree, dirSortOrders) {
|
||||
|
||||
const roadmap = require(path.join(
|
||||
__dirname,
|
||||
`../public/jsons/roadmaps/${roadmapId}`
|
||||
`../src/data/roadmaps/${roadmapId}/${roadmapId}`
|
||||
));
|
||||
|
||||
const controls = roadmap.mockup.controls.control;
|
||||
|
||||
// Prepare the dir tree that we will be creating and also calculate the sort orders
|
||||
@@ -22,7 +22,7 @@ function removeAllSponsors(baseContentDir) {
|
||||
|
||||
const contentDir = fs.readdirSync(contentDirPath);
|
||||
contentDir.forEach((content) => {
|
||||
console.log('Removing sponsor from: ', content);
|
||||
console.log('Removing sponsors from: ', content);
|
||||
|
||||
const pageFilePath = path.join(contentDirPath, content, `${content}.md`);
|
||||
const pageFileContent = fs.readFileSync(pageFilePath, 'utf8');
|
||||
@@ -35,7 +35,7 @@ function removeAllSponsors(baseContentDir) {
|
||||
.trim();
|
||||
|
||||
let frontmatterObj = yaml.load(existingFrontmatter);
|
||||
delete frontmatterObj.sponsor;
|
||||
delete frontmatterObj.sponsors;
|
||||
|
||||
const newFrontmatter = yaml.dump(frontmatterObj, {
|
||||
lineWidth: 10000,
|
||||
@@ -87,27 +87,23 @@ function addPageSponsor({
|
||||
.trim();
|
||||
|
||||
let frontmatterObj = yaml.load(existingFrontmatter);
|
||||
delete frontmatterObj.sponsor;
|
||||
const sponsors = frontmatterObj.sponsors || [];
|
||||
|
||||
const frontmatterValues = Object.entries(frontmatterObj);
|
||||
const roadmapLabel = frontmatterObj.briefTitle;
|
||||
|
||||
sponsors.push({
|
||||
url: redirectUrl,
|
||||
title: adTitle,
|
||||
imageUrl,
|
||||
description: adDescription,
|
||||
page: roadmapLabel,
|
||||
company,
|
||||
});
|
||||
|
||||
// Insert sponsor data at 10 index i.e. after
|
||||
// roadmap dimensions in the frontmatter
|
||||
frontmatterValues.splice(10, 0, [
|
||||
'sponsor',
|
||||
{
|
||||
url: redirectUrl,
|
||||
title: adTitle,
|
||||
imageUrl,
|
||||
description: adDescription,
|
||||
event: {
|
||||
category: 'SponsorClick',
|
||||
action: `${company} Redirect`,
|
||||
label: `${roadmapLabel} / ${company} Link`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
frontmatterValues.splice(10, 0, ['sponsors', sponsors]);
|
||||
|
||||
frontmatterObj = Object.fromEntries(frontmatterValues);
|
||||
|
||||
170
src/components/AccountSidebar.astro
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
|
||||
|
||||
export interface Props {
|
||||
activePageId: string;
|
||||
activePageTitle: string;
|
||||
hasDesktopSidebar?: boolean;
|
||||
}
|
||||
|
||||
const { hasDesktopSidebar = true, activePageId, activePageTitle } = Astro.props;
|
||||
|
||||
const sidebarLinks = [
|
||||
{
|
||||
href: '/account',
|
||||
title: 'Activity',
|
||||
id: 'activity',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'analytics',
|
||||
classes: 'h-3 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/road-card',
|
||||
title: 'Card',
|
||||
id: 'road-card',
|
||||
isNew: true,
|
||||
icon: {
|
||||
glyph: 'badge',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/update-profile',
|
||||
title: 'Profile',
|
||||
id: 'profile',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'user',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/settings',
|
||||
title: 'Settings',
|
||||
id: 'settings',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'cog',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<div class='relative mb-5 block border-b p-4 shadow-inner md:hidden'>
|
||||
<button
|
||||
class='flex h-10 w-full items-center justify-between rounded-md border bg-white px-2 text-center text-sm font-medium text-gray-900'
|
||||
id='settings-menu'
|
||||
>
|
||||
{activePageTitle}
|
||||
<AstroIcon icon='dropdown' />
|
||||
</button>
|
||||
<ul
|
||||
id='settings-menu-dropdown'
|
||||
class='absolute left-0 right-0 z-10 mt-1 hidden space-y-1.5 bg-white p-2 shadow-lg'
|
||||
>
|
||||
<!--<li>-->
|
||||
<!-- <a-->
|
||||
<!-- href='/team'-->
|
||||
<!-- class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${-->
|
||||
<!-- activePageId === 'team' ? 'bg-slate-100' : ''-->
|
||||
<!-- }`}-->
|
||||
<!-- >-->
|
||||
<!-- <AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />-->
|
||||
<!-- Teams-->
|
||||
<!-- </a>-->
|
||||
<!--</li>-->
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${
|
||||
isActive ? 'bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class='container flex min-h-screen items-stretch'>
|
||||
<!-- Start Desktop Sidebar -->
|
||||
{
|
||||
hasDesktopSidebar && (
|
||||
<aside class='hidden w-[195px] shrink-0 border-r border-slate-200 py-10 md:block'>
|
||||
<TeamDropdown client:load />
|
||||
|
||||
<nav>
|
||||
<ul class='space-y-1'>
|
||||
{sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`font-regular flex w-full items-center border-r-2 px-2 py-1.5 text-sm ${
|
||||
isActive
|
||||
? 'border-r-black bg-gray-100 text-black'
|
||||
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span class='flex flex-grow items-center'>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
|
||||
{sidebarLink.isNew && !isActive && (
|
||||
<span class='relative mr-1 flex items-center'>
|
||||
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
|
||||
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
<!-- /End Desktop Sidebar -->
|
||||
|
||||
<div class:list={['grow px-0 py-0 md:py-10', { 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar }]}>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const menuButton = document.getElementById('settings-menu');
|
||||
const menuDropdown = document.getElementById('settings-menu-dropdown');
|
||||
|
||||
menuButton?.addEventListener('click', () => {
|
||||
menuDropdown?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menuButton?.contains(e.target as Node)) {
|
||||
menuDropdown?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
56
src/components/Activity/ActivityCounters.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
type ActivityCountersType = {
|
||||
done: {
|
||||
today: number;
|
||||
total: number;
|
||||
};
|
||||
learning: {
|
||||
today: number;
|
||||
total: number;
|
||||
};
|
||||
streak: {
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ActivityCounterType = {
|
||||
text: string;
|
||||
count: string;
|
||||
};
|
||||
|
||||
function ActivityCounter(props: ActivityCounterType) {
|
||||
const { text, count } = props;
|
||||
|
||||
return (
|
||||
<div class="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
|
||||
<h2 class="text-base sm:text-5xl font-bold">
|
||||
{count}
|
||||
</h2>
|
||||
<p class="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityCounters(props: ActivityCountersType) {
|
||||
const { done, learning, streak } = props;
|
||||
|
||||
return (
|
||||
<div class="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
|
||||
<div class="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
|
||||
<ActivityCounter
|
||||
text={'Topics Completed'}
|
||||
count={`${done?.total || 0}`}
|
||||
/>
|
||||
|
||||
<ActivityCounter
|
||||
text={'Currently Learning'}
|
||||
count={`${learning?.total || 0}`}
|
||||
/>
|
||||
|
||||
<ActivityCounter
|
||||
text={'Visit Streak'}
|
||||
count={`${streak?.count || 0}d`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
src/components/Activity/ActivityPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { ActivityCounters } from './ActivityCounters';
|
||||
import { ResourceProgress } from './ResourceProgress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { EmptyActivity } from './EmptyActivity';
|
||||
|
||||
export type ActivityResponse = {
|
||||
done: {
|
||||
today: number;
|
||||
total: number;
|
||||
};
|
||||
learning: {
|
||||
today: number;
|
||||
total: number;
|
||||
roadmaps: {
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
done: number;
|
||||
total: number;
|
||||
skipped: number;
|
||||
updatedAt: string;
|
||||
}[];
|
||||
bestPractices: {
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: string;
|
||||
}[];
|
||||
};
|
||||
streak: {
|
||||
count: number;
|
||||
firstVisitAt: Date | null;
|
||||
lastVisitAt: Date | null;
|
||||
};
|
||||
activity: {
|
||||
type: 'done' | 'learning' | 'pending' | 'skipped';
|
||||
createdAt: Date;
|
||||
metadata: {
|
||||
resourceId?: string;
|
||||
resourceType?: 'roadmap' | 'best-practice';
|
||||
topicId?: string;
|
||||
topicLabel?: string;
|
||||
resourceTitle?: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
export function ActivityPage() {
|
||||
const [activity, setActivity] = useState<ActivityResponse>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function loadActivity() {
|
||||
const { error, response } = await httpGet<ActivityResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-stats`
|
||||
);
|
||||
|
||||
if (!response || error) {
|
||||
console.error('Error loading activity');
|
||||
console.error(error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setActivity(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadActivity().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const learningRoadmaps = activity?.learning.roadmaps || [];
|
||||
const learningBestPractices = activity?.learning.bestPractices || [];
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityCounters
|
||||
done={activity?.done || { today: 0, total: 0 }}
|
||||
learning={activity?.learning || { today: 0, total: 0 }}
|
||||
streak={activity?.streak || { count: 0 }}
|
||||
/>
|
||||
|
||||
<div class="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
{learningRoadmaps.length === 0 &&
|
||||
learningBestPractices.length === 0 && <EmptyActivity />}
|
||||
|
||||
{(learningRoadmaps.length > 0 || learningBestPractices.length > 0) && (
|
||||
<>
|
||||
<h2 class="mb-3 text-xs uppercase text-gray-400">
|
||||
Continue Following
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
{learningRoadmaps
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
const updatedAtB = new Date(b.updatedAt);
|
||||
|
||||
return updatedAtB.getTime() - updatedAtA.getTime();
|
||||
})
|
||||
.map((roadmap) => (
|
||||
<ResourceProgress
|
||||
doneCount={roadmap.done || 0}
|
||||
learningCount={roadmap.learning || 0}
|
||||
totalCount={roadmap.total || 0}
|
||||
skippedCount={roadmap.skipped || 0}
|
||||
resourceId={roadmap.id}
|
||||
resourceType={'roadmap'}
|
||||
updatedAt={roadmap.updatedAt}
|
||||
title={roadmap.title}
|
||||
onCleared={() => {
|
||||
pageProgressMessage.set('Updating activity');
|
||||
loadActivity().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{learningBestPractices
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
const updatedAtB = new Date(b.updatedAt);
|
||||
|
||||
return updatedAtB.getTime() - updatedAtA.getTime();
|
||||
})
|
||||
.map((bestPractice) => (
|
||||
<ResourceProgress
|
||||
doneCount={bestPractice.done || 0}
|
||||
totalCount={bestPractice.total || 0}
|
||||
learningCount={bestPractice.learning || 0}
|
||||
resourceId={bestPractice.id}
|
||||
skippedCount={bestPractice.skipped || 0}
|
||||
resourceType={'best-practice'}
|
||||
title={bestPractice.title}
|
||||
updatedAt={bestPractice.updatedAt}
|
||||
onCleared={() => {
|
||||
pageProgressMessage.set('Updating activity');
|
||||
loadActivity().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/components/Activity/EmptyActivity.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
|
||||
export function EmptyActivity() {
|
||||
return (
|
||||
<div class="rounded-md">
|
||||
<div class="flex flex-col items-center p-7 text-center">
|
||||
<img
|
||||
alt="no roadmaps"
|
||||
src={RoadmapIcon}
|
||||
class="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10"
|
||||
/>
|
||||
<h2 class="text-lg sm:text-xl font-bold">No Progress</h2>
|
||||
<p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base">
|
||||
Progress will appear here as you start tracking your{' '}
|
||||
<a href="/roadmaps" class="mt-4 text-blue-500 hover:underline">
|
||||
Roadmaps
|
||||
</a>{' '}
|
||||
or{' '}
|
||||
<a href="/best-practices" class="mt-4 text-blue-500 hover:underline">
|
||||
Best Practices
|
||||
</a>{' '}
|
||||
progress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/components/Activity/ResourceProgress.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceId: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
totalCount: number;
|
||||
doneCount: number;
|
||||
learningCount: number;
|
||||
skippedCount: number;
|
||||
onCleared?: () => void;
|
||||
showClearButton?: boolean;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const { showClearButton = true } = props;
|
||||
const toast = useToast();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const {
|
||||
updatedAt,
|
||||
resourceType,
|
||||
resourceId,
|
||||
title,
|
||||
totalCount,
|
||||
learningCount,
|
||||
doneCount,
|
||||
skippedCount,
|
||||
onCleared,
|
||||
} = props;
|
||||
|
||||
async function clearProgress() {
|
||||
setIsClearing(true);
|
||||
const { error, response } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`,
|
||||
{
|
||||
resourceId,
|
||||
resourceType,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error('Error clearing progress. Please try again.');
|
||||
console.error(error);
|
||||
setIsClearing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
|
||||
|
||||
setIsClearing(false);
|
||||
setIsConfirming(false);
|
||||
if (onCleared) {
|
||||
onCleared();
|
||||
}
|
||||
}
|
||||
|
||||
const url =
|
||||
resourceType === 'roadmap'
|
||||
? `/${resourceId}`
|
||||
: `/best-practices/${resourceId}`;
|
||||
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a
|
||||
href={url}
|
||||
className="group relative flex cursor-pointer items-center rounded-t-md border p-3 text-gray-600 hover:border-gray-300 hover:text-black"
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10`}
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
></span>
|
||||
<span className="relative flex-1 cursor-pointer truncate">
|
||||
{title}
|
||||
</span>
|
||||
<span className="ml-1 cursor-pointer text-sm text-gray-400">
|
||||
{getRelativeTimeString(updatedAt)}
|
||||
</span>
|
||||
</a>
|
||||
<p className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
|
||||
<span className="hidden flex-1 gap-1 sm:flex">
|
||||
{doneCount > 0 && (
|
||||
<>
|
||||
<span>{doneCount} done</span> •
|
||||
</>
|
||||
)}
|
||||
{learningCount > 0 && (
|
||||
<>
|
||||
<span>{learningCount} in progress</span> •
|
||||
</>
|
||||
)}
|
||||
{skippedCount > 0 && (
|
||||
<>
|
||||
<span>{skippedCount} skipped</span> •
|
||||
</>
|
||||
)}
|
||||
<span>{totalCount} total</span>
|
||||
</span>
|
||||
{showClearButton && (
|
||||
<>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isClearing && 'Processing...'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span>
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={clearProgress}
|
||||
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/components/AddTeamRoadmap.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { OptionType, SearchSelector } from './SearchSelector';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
import { httpPut } from '../lib/http';
|
||||
import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
|
||||
import { Spinner } from './ReactIcons/Spinner';
|
||||
|
||||
type AddTeamRoadmapProps = {
|
||||
teamId: string;
|
||||
allRoadmaps: PageType[];
|
||||
availableRoadmaps: PageType[];
|
||||
onClose: () => void;
|
||||
onMakeChanges: (roadmapId: string) => void;
|
||||
setResourceConfigs: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
const {
|
||||
teamId,
|
||||
onMakeChanges,
|
||||
onClose,
|
||||
allRoadmaps,
|
||||
availableRoadmaps,
|
||||
setResourceConfigs,
|
||||
} = props;
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedRoadmap, setSelectedRoadmap] = useState<string>('');
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
async function addTeamResource(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
const selectedRoadmapTitle = allRoadmaps.find(
|
||||
(roadmap) => roadmap.id === selectedRoadmap
|
||||
)?.title;
|
||||
|
||||
return (
|
||||
<div class="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
{isLoading && (
|
||||
<>
|
||||
<div class="flex items-center justify-center gap-2 py-8">
|
||||
<Spinner isDualRing={false} className="h-4 w-4" />
|
||||
<h2 className="font-medium">Loading...</h2>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && !error && selectedRoadmap && (
|
||||
<div className={'text-center'}>
|
||||
<CheckIcon additionalClasses="h-10 w-10 mx-auto opacity-20 mb-3 mt-4" />
|
||||
<h3 class="mb-1.5 text-2xl font-medium">
|
||||
{selectedRoadmapTitle} Added
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-none text-gray-400">
|
||||
<button
|
||||
onClick={() => onMakeChanges(selectedRoadmap)}
|
||||
className="underline underline-offset-2 hover:text-gray-900"
|
||||
>
|
||||
Click here
|
||||
</button>{' '}
|
||||
to make changes to the roadmap.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRoadmap('');
|
||||
setError('');
|
||||
setIsLoading(false);
|
||||
}}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
|
||||
>
|
||||
+ Add More
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<>
|
||||
<h3 class="mb-1.5 text-2xl font-medium">Error</h3>
|
||||
<p className="mb-3 text-sm leading-none text-red-400">{error}</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && !error && !selectedRoadmap && (
|
||||
<>
|
||||
<h3 class="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
|
||||
<p className="mb-3 text-sm leading-none text-gray-400">
|
||||
Search and add a roadmap
|
||||
</p>
|
||||
|
||||
<SearchSelector
|
||||
options={availableRoadmaps.map((roadmap) => ({
|
||||
value: roadmap.id,
|
||||
label: roadmap.title,
|
||||
}))}
|
||||
onSelect={(option: OptionType) => {
|
||||
const roadmapId = option.value;
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
setIsLoading(false);
|
||||
setSelectedRoadmap(roadmapId);
|
||||
});
|
||||
}}
|
||||
inputClassName="mt-2 mb-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
|
||||
placeholder={'Search for roadmap'}
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,17 @@
|
||||
---
|
||||
---
|
||||
|
||||
<script src='./analytics.js'></script>
|
||||
<script src='./analytics.ts'></script>
|
||||
<script async src='https://www.googletagmanager.com/gtag/js?id=UA-139582634-1'
|
||||
></script>
|
||||
<script is:inline>
|
||||
// @ts-nocheck
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'UA-139582634-1');
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
let trackEl = e.target;
|
||||
if (!trackEl.getAttribute('ga-category')) {
|
||||
trackEl = trackEl.closest('[ga-category]');
|
||||
}
|
||||
|
||||
if (!trackEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const category = trackEl.getAttribute('ga-category');
|
||||
const action = trackEl.getAttribute('ga-action');
|
||||
const label = trackEl.getAttribute('ga-label');
|
||||
|
||||
if (!category) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.fireEvent({
|
||||
category,
|
||||
action,
|
||||
label,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// To selectively enable/disable debug logs
|
||||
__DEBUG__: boolean;
|
||||
gtag: any;
|
||||
fireEvent: (props: GAEventType) => void;
|
||||
fireEvent: (props: {
|
||||
action: string;
|
||||
category: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
}) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export type GAEventType = {
|
||||
action: string;
|
||||
category: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks the event on google analytics
|
||||
* @see https://developers.google.com/analytics/devguides/collection/gtagjs/events
|
||||
* @param props Event properties
|
||||
* @returns void
|
||||
*/
|
||||
window.fireEvent = (props: GAEventType) => {
|
||||
window.fireEvent = (props) => {
|
||||
const { action, category, label, value } = props;
|
||||
if (!window.gtag) {
|
||||
console.warn('Missing GTAG - Analytics disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.__DEBUG__) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Analytics event fired', props);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,5 +35,4 @@ const { attributes: baseAttributes, innerHTML } = await getSVG(icon);
|
||||
const svgAttributes = { ...baseAttributes, ...attributes };
|
||||
---
|
||||
|
||||
|
||||
<svg {...svgAttributes} set:html={innerHTML}></svg>
|
||||
<svg {...svgAttributes} set:html={innerHTML}></svg>
|
||||
5
src/components/AuthenticationFlow/Divider.astro
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class='flex w-full items-center gap-2 py-6 text-sm text-slate-600'>
|
||||
<div class='h-px w-full bg-slate-200'></div>
|
||||
OR
|
||||
<div class='h-px w-full bg-slate-200'></div>
|
||||
</div>
|
||||
103
src/components/AuthenticationFlow/EmailLoginForm.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import type { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
const EmailLoginForm: FunctionComponent<{}> = () => {
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const handleFormSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-login`,
|
||||
{
|
||||
email,
|
||||
password,
|
||||
}
|
||||
);
|
||||
|
||||
// Log the user in and reload the page
|
||||
if (response?.token) {
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.reload();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// @todo use proper types
|
||||
if ((error as any).type === 'user_not_verified') {
|
||||
window.location.href = `/verification-pending?email=${encodeURIComponent(
|
||||
email
|
||||
)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong. Please try again later.');
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="w-full" onSubmit={handleFormSubmit}>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Email Address"
|
||||
value={email}
|
||||
onInput={(e) => setEmail(String((e.target as any).value))}
|
||||
/>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onInput={(e) => setPassword(String((e.target as any).value))}
|
||||
/>
|
||||
|
||||
<p class="mb-3 mt-2 text-sm text-gray-500">
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-blue-800 hover:text-blue-600"
|
||||
>
|
||||
Reset your password?
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="mb-2 rounded-md bg-red-100 p-2 text-red-800">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailLoginForm;
|
||||
103
src/components/AuthenticationFlow/EmailSignupForm.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
const EmailSignupForm: FunctionComponent = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ status: 'ok' }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-register`,
|
||||
{
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || response?.status !== 'ok') {
|
||||
setIsLoading(false);
|
||||
setError(
|
||||
error?.message || 'Something went wrong. Please try again later.'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/verification-pending?email=${encodeURIComponent(
|
||||
email
|
||||
)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex w-full flex-col gap-2" onSubmit={onSubmit}>
|
||||
<label htmlFor="name" className="sr-only">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
min={3}
|
||||
max={50}
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Full Name"
|
||||
value={name}
|
||||
onInput={(e) => setName(String((e.target as any).value))}
|
||||
/>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Email Address"
|
||||
value={email}
|
||||
onInput={(e) => setEmail(String((e.target as any).value))}
|
||||
/>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
min={6}
|
||||
max={50}
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onInput={(e) => setPassword(String((e.target as any).value))}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg bg-red-100 p-2 text-red-700">{error}.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue to Verify Email'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailSignupForm;
|
||||
64
src/components/AuthenticationFlow/ForgotPasswordForm.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-forgot-password`,
|
||||
{
|
||||
email,
|
||||
}
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setEmail('');
|
||||
setSuccess('Check your email for a link to reset your password.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class="w-full">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
placeholder="Email Address"
|
||||
value={email}
|
||||
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<p className="mt-2 rounded-lg bg-green-100 p-2 text-sm text-green-700">
|
||||
{success}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="mt-3 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
124
src/components/AuthenticationFlow/GitHubButton.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import GitHubIcon from '../../icons/github.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
type GitHubButtonProps = {};
|
||||
|
||||
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
|
||||
const GITHUB_LAST_PAGE = 'githubLastPage';
|
||||
|
||||
export function GitHubButton(props: GitHubButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : GitHubIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const provider = urlParams.get('provider');
|
||||
|
||||
if (!code || !state || provider !== 'github') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-callback${
|
||||
window.location.search
|
||||
}`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
const errMessage = error?.message || 'Something went wrong.';
|
||||
setError(errMessage);
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = '/';
|
||||
const gitHubRedirectAt = localStorage.getItem(GITHUB_REDIRECT_AT);
|
||||
const lastPageBeforeGithub = localStorage.getItem(GITHUB_LAST_PAGE);
|
||||
|
||||
// If the social redirect is there and less than 30 seconds old
|
||||
// redirect to the page that user was on before they clicked the github login button
|
||||
if (gitHubRedirectAt && lastPageBeforeGithub) {
|
||||
const socialRedirectAtTime = parseInt(gitHubRedirectAt, 10);
|
||||
const now = Date.now();
|
||||
const timeSinceRedirect = now - socialRedirectAtTime;
|
||||
|
||||
if (timeSinceRedirect < 30 * 1000) {
|
||||
redirectUrl = lastPageBeforeGithub;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(GITHUB_REDIRECT_AT);
|
||||
localStorage.removeItem(GITHUB_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response, error } = await httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`
|
||||
);
|
||||
|
||||
if (error || !response?.loginUrl) {
|
||||
setError(
|
||||
error?.message || 'Something went wrong. Please try again later.'
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GITHUB_LAST_PAGE, pagePath);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="GitHub"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with GitHub
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
src/components/AuthenticationFlow/GoogleButton.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import GoogleIcon from '../../icons/google.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
type GoogleButtonProps = {};
|
||||
|
||||
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
|
||||
const GOOGLE_LAST_PAGE = 'googleLastPage';
|
||||
|
||||
export function GoogleButton(props: GoogleButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : GoogleIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const provider = urlParams.get('provider');
|
||||
|
||||
if (!code || !state || provider !== 'google') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-callback${
|
||||
window.location.search
|
||||
}`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = '/';
|
||||
const googleRedirectAt = localStorage.getItem(GOOGLE_REDIRECT_AT);
|
||||
const lastPageBeforeGoogle = localStorage.getItem(GOOGLE_LAST_PAGE);
|
||||
|
||||
// If the social redirect is there and less than 30 seconds old
|
||||
// redirect to the page that user was on before they clicked the github login button
|
||||
if (googleRedirectAt && lastPageBeforeGoogle) {
|
||||
const socialRedirectAtTime = parseInt(googleRedirectAt, 10);
|
||||
const now = Date.now();
|
||||
const timeSinceRedirect = now - socialRedirectAtTime;
|
||||
|
||||
if (timeSinceRedirect < 30 * 1000) {
|
||||
redirectUrl = lastPageBeforeGoogle;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(GOOGLE_REDIRECT_AT);
|
||||
localStorage.removeItem(GOOGLE_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-login`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="Google"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with Google
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
src/components/AuthenticationFlow/LinkedInButton.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import LinkedIn from '../../icons/linkedin.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
type LinkedInButtonProps = {};
|
||||
|
||||
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
|
||||
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
|
||||
|
||||
export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : LinkedIn;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const provider = urlParams.get('provider');
|
||||
|
||||
if (!code || !state || provider !== 'linkedin') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-callback${
|
||||
window.location.search
|
||||
}`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = '/';
|
||||
const linkedInRedirectAt = localStorage.getItem(LINKEDIN_REDIRECT_AT);
|
||||
const lastPageBeforeLinkedIn = localStorage.getItem(LINKEDIN_LAST_PAGE);
|
||||
|
||||
// If the social redirect is there and less than 30 seconds old
|
||||
// redirect to the page that user was on before they clicked the github login button
|
||||
if (linkedInRedirectAt && lastPageBeforeLinkedIn) {
|
||||
const socialRedirectAtTime = parseInt(linkedInRedirectAt, 10);
|
||||
const now = Date.now();
|
||||
const timeSinceRedirect = now - socialRedirectAtTime;
|
||||
|
||||
if (timeSinceRedirect < 30 * 1000) {
|
||||
redirectUrl = lastPageBeforeLinkedIn;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
|
||||
localStorage.removeItem(LINKEDIN_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(LINKEDIN_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(LINKEDIN_LAST_PAGE, pagePath);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="Google"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with LinkedIn
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
src/components/AuthenticationFlow/LoginPopup.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import Popup from '../Popup/Popup.astro';
|
||||
import EmailLoginForm from './EmailLoginForm';
|
||||
import Divider from './Divider.astro';
|
||||
import { GitHubButton } from './GitHubButton';
|
||||
import { GoogleButton } from './GoogleButton';
|
||||
import { LinkedInButton } from './LinkedInButton';
|
||||
---
|
||||
|
||||
<Popup id='login-popup' title='' subtitle=''>
|
||||
<div class='text-center'>
|
||||
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
|
||||
Login to your account
|
||||
</h2>
|
||||
<p class='mt-2 text-sm leading-4 text-slate-600'>
|
||||
You must be logged in to perform this action.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class='mt-7 flex flex-col gap-2'>
|
||||
<GitHubButton client:load />
|
||||
<GoogleButton client:load />
|
||||
<LinkedInButton client:load />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<EmailLoginForm client:load />
|
||||
|
||||
<div class='mt-6 text-center text-sm text-slate-600'>
|
||||
Don't have an account?{' '}
|
||||
<a href='/signup' class='font-medium text-[#4285f4]'>Sign up</a>
|
||||
</div>
|
||||
</Popup>
|
||||
100
src/components/AuthenticationFlow/ResetPasswordForm.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
export default function ResetPasswordForm() {
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (!code) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
setCode(code);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
setIsLoading(false);
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-reset-forgotten-password`,
|
||||
{
|
||||
newPassword: password,
|
||||
confirmPassword: passwordConfirm,
|
||||
code,
|
||||
}
|
||||
);
|
||||
|
||||
if (error?.message) {
|
||||
setIsLoading(false);
|
||||
setError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response?.token) {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = response.token;
|
||||
Cookies.set(TOKEN_COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="mx-auto w-full" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
className="mb-2 mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="New Password"
|
||||
value={password}
|
||||
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="Confirm New Password"
|
||||
value={passwordConfirm}
|
||||
onInput={(e) =>
|
||||
setPasswordConfirm((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
82
src/components/AuthenticationFlow/TriggerVerifyAccount.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import ErrorIcon from '../../icons/error.svg';
|
||||
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function TriggerVerifyAccount() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const triggerVerify = (code: string) => {
|
||||
setIsLoading(true);
|
||||
|
||||
httpPost<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-verify-account`,
|
||||
{
|
||||
code,
|
||||
}
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong. Please try again.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = '/';
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again.');
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code')!;
|
||||
|
||||
if (!code) {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
triggerVerify(code);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
{isLoading && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={SpinnerIcon}
|
||||
class={'mx-auto h-16 w-16 animate-spin'}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={ErrorIcon}
|
||||
className={'mx-auto h-16 w-16'}
|
||||
/>
|
||||
)}
|
||||
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl">
|
||||
Verifying your account
|
||||
</h2>
|
||||
<div className="text-sm sm:text-base">
|
||||
{isLoading && <p>Please wait while we verify your account..</p>}
|
||||
{error && <p class="text-red-700">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import VerifyLetterIcon from '../../icons/verify-letter.svg';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function VerificationEmailMessage() {
|
||||
const [email, setEmail] = useState('..');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEmailResent, setIsEmailResent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
setEmail(urlParams.get('email')!);
|
||||
}, []);
|
||||
|
||||
const resendVerificationEmail = () => {
|
||||
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-send-verification-email`, {
|
||||
email,
|
||||
})
|
||||
.then(({ response, error }) => {
|
||||
if (error) {
|
||||
setIsEmailResent(false);
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEmailResent(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsEmailResent(false);
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again later.');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<img
|
||||
alt="Verify Email"
|
||||
src={VerifyLetterIcon}
|
||||
class="mx-auto mb-4 h-20 w-40 sm:h-40"
|
||||
/>
|
||||
<h2 class="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
|
||||
Verify your email address
|
||||
</h2>
|
||||
<div class="text-sm sm:text-base">
|
||||
<p>
|
||||
We have sent you an email at{' '}
|
||||
<span className="font-bold">{email}</span>. Please click the link to
|
||||
verify your account. This link will expire shortly, so please verify
|
||||
soon!
|
||||
</p>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
{!isEmailResent && (
|
||||
<>
|
||||
{isLoading && <p className="text-gray-400">Sending the email ..</p>}
|
||||
{!isLoading && !error && (
|
||||
<p>
|
||||
Please make sure to check your spam folder. If you still don't
|
||||
have the email click to{' '}
|
||||
<button
|
||||
disabled={!email}
|
||||
className="inline text-blue-700"
|
||||
onClick={resendVerificationEmail}
|
||||
>
|
||||
resend verification email.
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p class="text-red-700">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEmailResent && (
|
||||
<p class="text-green-700">Verification email has been sent!</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/components/Authenticator/Authenticator.astro
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
---
|
||||
|
||||
<script src='./authenticator.ts'></script>
|
||||
89
src/components/Authenticator/authenticator.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
function easeInElement(el: Element) {
|
||||
el.classList.add('opacity-0', 'transition-opacity', 'duration-300');
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
el.classList.remove('opacity-0');
|
||||
});
|
||||
}
|
||||
|
||||
function showHideAuthElements(hideOrShow: 'hide' | 'show' = 'hide') {
|
||||
document.querySelectorAll('[data-auth-required]').forEach((el) => {
|
||||
if (hideOrShow === 'hide') {
|
||||
el.classList.add('hidden');
|
||||
} else {
|
||||
easeInElement(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
|
||||
document.querySelectorAll('[data-guest-required]').forEach((el) => {
|
||||
if (hideOrShow === 'hide') {
|
||||
el.classList.add('hidden');
|
||||
} else {
|
||||
easeInElement(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prepares the UI for the user who is logged in
|
||||
function handleGuest() {
|
||||
const authenticatedRoutes = [
|
||||
'/account/update-profile',
|
||||
'/account/notification',
|
||||
'/account/update-password',
|
||||
'/account/settings',
|
||||
'/account/road-card',
|
||||
'/account',
|
||||
'/team',
|
||||
'/team/progress',
|
||||
'/team/roadmaps',
|
||||
'/team/new',
|
||||
'/team/members',
|
||||
'/team/settings'
|
||||
];
|
||||
|
||||
showHideAuthElements('hide');
|
||||
showHideGuestElements('show');
|
||||
|
||||
// If the user is on an authenticated route, redirect them to the home page
|
||||
if (authenticatedRoutes.includes(window.location.pathname)) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Prepares the UI for the user who is logged out
|
||||
function handleAuthenticated() {
|
||||
const guestRoutes = [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/verify-account',
|
||||
'/verification-pending',
|
||||
'/reset-password',
|
||||
'/forgot-password',
|
||||
];
|
||||
|
||||
showHideGuestElements('hide');
|
||||
showHideAuthElements('show');
|
||||
|
||||
// If the user is on a guest route, redirect them to the home page
|
||||
if (guestRoutes.includes(window.location.pathname)) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAuthRequired() {
|
||||
const token = Cookies.get(TOKEN_COOKIE_NAME);
|
||||
if (token) {
|
||||
handleAuthenticated();
|
||||
} else {
|
||||
handleGuest();
|
||||
}
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
handleAuthRequired();
|
||||
}, 0);
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
import Icon from './AstroIcon.astro';
|
||||
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
|
||||
import BestPracticeHint from './BestPracticeHint.astro';
|
||||
import DownloadPopup from './DownloadPopup.astro';
|
||||
import Icon from './Icon.astro';
|
||||
import SubscribePopup from './SubscribePopup.astro';
|
||||
import { MarkFavorite } from './FeaturedItems/MarkFavorite';
|
||||
import ProgressHelpPopup from './ProgressHelpPopup.astro';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -15,54 +16,71 @@ const { title, description, bestPracticeId, isUpcoming = false } = Astro.props;
|
||||
const isBestPracticeReady = !isUpcoming;
|
||||
---
|
||||
|
||||
<DownloadPopup />
|
||||
<SubscribePopup />
|
||||
<LoginPopup />
|
||||
<ProgressHelpPopup />
|
||||
|
||||
<div class='border-b'>
|
||||
<div class='py-5 sm:py-12 container relative'>
|
||||
<div class='mt-0 mb-3 sm:mb-6'>
|
||||
<h1 class='text-2xl sm:text-4xl mb-0.5 sm:mb-2 font-bold'>
|
||||
<div class="border-b">
|
||||
<div class="container relative py-5 sm:py-12">
|
||||
<div class="mb-3 mt-0 sm:mb-6">
|
||||
<h1 class="mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl">
|
||||
{title}
|
||||
<MarkFavorite
|
||||
resourceId={bestPracticeId}
|
||||
resourceType="best-practice"
|
||||
className="text-gray-500 !opacity-100 hover:text-gray-600 [&>svg]:stroke-[0.4] [&>svg]:stroke-gray-400 hover:[&>svg]:stroke-gray-600 [&>svg]:h-4 [&>svg]:w-4 sm:[&>svg]:h-5 sm:[&>svg]:w-5 ml-1.5 relative focus:outline-0"
|
||||
client:load
|
||||
/>
|
||||
</h1>
|
||||
<p class='text-gray-500 text-sm sm:text-lg'>{description}</p>
|
||||
<p class="text-sm text-gray-500 sm:text-lg">{description}</p>
|
||||
</div>
|
||||
|
||||
<div class='flex justify-between'>
|
||||
<div class='flex gap-1 sm:gap-2'>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-1 sm:gap-2">
|
||||
<a
|
||||
href='/best-practices'
|
||||
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
|
||||
aria-label='Back to All Best Practices'
|
||||
href="/best-practices"
|
||||
class="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
aria-label="Back to All Best Practices"
|
||||
>
|
||||
←<span class='hidden sm:inline'> All Best Practices</span>
|
||||
←<span class="hidden sm:inline"> All Best Practices</span>
|
||||
</a>
|
||||
|
||||
{
|
||||
isBestPracticeReady && (
|
||||
<button
|
||||
data-popup='download-popup'
|
||||
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
|
||||
aria-label='Download Best Practice'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
ga-label='Download Best Practice Popup'
|
||||
data-guest-required
|
||||
data-popup="login-popup"
|
||||
class="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
|
||||
aria-label="Download Roadmap"
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='hidden sm:inline ml-2'>Download</span>
|
||||
<Icon icon="download" />
|
||||
<span class="ml-2 hidden sm:inline">Download</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
isBestPracticeReady && (
|
||||
<a
|
||||
data-auth-required
|
||||
class="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
|
||||
aria-label="Download Roadmap"
|
||||
target="_blank"
|
||||
href={`/pdfs/best-practices/${bestPracticeId}.pdf`}
|
||||
>
|
||||
<Icon icon="download" />
|
||||
<span class="ml-2 hidden sm:inline">Download</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
data-popup='subscribe-popup'
|
||||
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
|
||||
aria-label='Subscribe for Updates'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
ga-label='Subscribe Best Practice Popup'
|
||||
data-guest-required
|
||||
data-popup="login-popup"
|
||||
class="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
|
||||
aria-label="Subscribe for Updates"
|
||||
>
|
||||
<Icon icon='email' />
|
||||
<span class='ml-2'>Subscribe</span>
|
||||
<Icon icon="email" />
|
||||
<span class="ml-2">Subscribe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -70,13 +88,13 @@ const isBestPracticeReady = !isUpcoming;
|
||||
isBestPracticeReady && (
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
target='_blank'
|
||||
class='inline-flex items-center justify-center bg-gray-500 text-white py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-gray-600'
|
||||
aria-label='Suggest Changes'
|
||||
target="_blank"
|
||||
class="inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
aria-label="Suggest Changes"
|
||||
>
|
||||
<Icon icon='comment' class='h-3 w-3' />
|
||||
<span class='ml-2 hidden sm:inline'>Suggest Changes</span>
|
||||
<span class='ml-2 inline sm:hidden'>Suggest</span>
|
||||
<Icon icon="comment" class="h-3 w-3" />
|
||||
<span class="ml-2 hidden sm:inline">Suggest Changes</span>
|
||||
<span class="ml-2 inline sm:hidden">Suggest</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
---
|
||||
import ResourceProgressStats from './ResourceProgressStats.astro';
|
||||
export interface Props {
|
||||
bestPracticeId: string;
|
||||
}
|
||||
---
|
||||
|
||||
<div class='mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 sm:-mb-[65px]'>
|
||||
<!-- Desktop: Roadmap Resources - Alert -->
|
||||
<div class='hidden sm:flex justify-between px-2 bg-white items-center rounded-md p-1.5'>
|
||||
<p class='text-sm'>
|
||||
<span class='text-yellow-900 bg-yellow-200 py-0.5 px-1 text-xs rounded-sm font-medium uppercase mr-0.5'>Tip</span>
|
||||
Click the best practices for details and resources
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mobile - Roadmap resources alert -->
|
||||
<p class='block sm:hidden text-sm border border-yellow-500 text-yellow-700 rounded-md py-1.5 px-2 bg-white relative'>
|
||||
Click the best practices for details and resources
|
||||
</p>
|
||||
<ResourceProgressStats />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const { breadcrumbs, roadmapId } = Astro.props;
|
||||
---
|
||||
|
||||
<div class='py-7 pb-6'>
|
||||
<!-- Desktop breadcrums -->
|
||||
<!-- Desktop breadcrumbs -->
|
||||
<p class='text-gray-500 container hidden sm:block'>
|
||||
{
|
||||
breadcrumbs.map((breadcrumb, counter) => {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
---
|
||||
|
||||
<div class='recaptcha-field mb-2'></div>
|
||||
<input type='hidden' name='g-recaptcha-response' class='recaptcha-response' />
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script src='./captcha.js'></script>
|
||||
|
||||
<script is:inline>
|
||||
window.onCaptchaLoad = function () {
|
||||
if (!window.grecaptcha) {
|
||||
console.warn('window.grecaptcha is not defined');
|
||||
return;
|
||||
}
|
||||
|
||||
const recaptchaFields = document.querySelectorAll('.recaptcha-field');
|
||||
|
||||
// render recaptcha on fields
|
||||
recaptchaFields.forEach((field) => {
|
||||
// If captcha already rendered for this field
|
||||
if (field.hasAttribute('data-recaptcha-id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderedId = window.grecaptcha.render(field, {
|
||||
sitekey: '6Ldn2YsjAAAAABlUxNxukAuDAUIuZIhO0hRVxzJW',
|
||||
});
|
||||
|
||||
field.setAttribute('data-recaptcha-id', renderedId);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<script
|
||||
src='https://www.google.com/recaptcha/api.js?onload=onCaptchaLoad&render=explicit'
|
||||
async
|
||||
defer
|
||||
></script>
|
||||
@@ -1,49 +0,0 @@
|
||||
class Captcha {
|
||||
constructor() {
|
||||
this.onDOMLoaded = this.onDOMLoaded.bind(this);
|
||||
this.bindValidation = this.bindValidation.bind(this);
|
||||
this.validateCaptchaBeforeSubmit =
|
||||
this.validateCaptchaBeforeSubmit.bind(this);
|
||||
}
|
||||
|
||||
validateCaptchaBeforeSubmit(e) {
|
||||
const target = e.target;
|
||||
const captchaField = target.querySelector('.recaptcha-field');
|
||||
|
||||
if (captchaField) {
|
||||
const captchaId = captchaField.dataset.recaptchaId;
|
||||
const captchaResponse = window.grecaptcha.getResponse(captchaId);
|
||||
|
||||
// If valid captcha is not present, prevent form submission
|
||||
if (!captchaResponse) {
|
||||
e.preventDefault();
|
||||
alert('Please verify that you are human first');
|
||||
return false;
|
||||
}
|
||||
|
||||
target.querySelector('.recaptcha-response').value = captchaResponse;
|
||||
}
|
||||
|
||||
target.closest('.popup').classList.add('hidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
bindValidation() {
|
||||
const forms = document.querySelectorAll('[captcha-form]');
|
||||
|
||||
forms.forEach((form) => {
|
||||
form.addEventListener('submit', this.validateCaptchaBeforeSubmit);
|
||||
});
|
||||
}
|
||||
|
||||
onDOMLoaded() {
|
||||
this.bindValidation();
|
||||
}
|
||||
|
||||
init() {
|
||||
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
|
||||
}
|
||||
}
|
||||
|
||||
const captcha = new Captcha();
|
||||
captcha.init();
|
||||
234
src/components/CommandMenu/CommandMenu.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import BestPracticesIcon from '../../icons/best-practices.svg';
|
||||
import GuideIcon from '../../icons/guide.svg';
|
||||
import HomeIcon from '../../icons/home.svg';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import UserIcon from '../../icons/user.svg';
|
||||
import GroupIcon from '../../icons/group.svg';
|
||||
import VideoIcon from '../../icons/video.svg';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
export type PageType = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
group: string;
|
||||
icon?: string;
|
||||
isProtected?: boolean;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
|
||||
{
|
||||
id: 'account',
|
||||
url: '/account',
|
||||
title: 'Account',
|
||||
group: 'Pages',
|
||||
icon: UserIcon,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
url: '/team',
|
||||
title: 'Teams',
|
||||
group: 'Pages',
|
||||
icon: GroupIcon,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'roadmaps',
|
||||
url: '/roadmaps',
|
||||
title: 'Roadmaps',
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon,
|
||||
},
|
||||
{
|
||||
id: 'best-practices',
|
||||
url: '/best-practices',
|
||||
title: 'Best Practices',
|
||||
group: 'Pages',
|
||||
icon: BestPracticesIcon,
|
||||
},
|
||||
{
|
||||
id: 'guides',
|
||||
url: '/guides',
|
||||
title: 'Guides',
|
||||
group: 'Pages',
|
||||
icon: GuideIcon,
|
||||
},
|
||||
{
|
||||
id: 'videos',
|
||||
url: '/videos',
|
||||
title: 'Videos',
|
||||
group: 'Pages',
|
||||
icon: VideoIcon,
|
||||
},
|
||||
];
|
||||
|
||||
function shouldShowPage(page: PageType) {
|
||||
const isUser = isLoggedIn();
|
||||
|
||||
return !page.isProtected || isUser;
|
||||
}
|
||||
|
||||
export function CommandMenu() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLInputElement>(null);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [allPages, setAllPages] = useState<PageType[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<PageType[]>(defaultPages);
|
||||
const [searchedText, setSearchedText] = useState('');
|
||||
const [activeCounter, setActiveCounter] = useState(0);
|
||||
|
||||
useKeydown('mod_k', () => {
|
||||
setIsActive(true);
|
||||
});
|
||||
|
||||
useOutsideClick(modalRef, () => {
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleToggleTopic(e: any) {
|
||||
setIsActive(true);
|
||||
}
|
||||
|
||||
getAllPages();
|
||||
window.addEventListener(`command.k`, handleToggleTopic);
|
||||
return () => {
|
||||
window.removeEventListener(`command.k`, handleToggleTopic);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !inputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current.focus();
|
||||
}, [isActive]);
|
||||
|
||||
async function getAllPages() {
|
||||
if (allPages.length > 0) {
|
||||
return allPages;
|
||||
}
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
if (!response) {
|
||||
return defaultPages.filter(shouldShowPage);
|
||||
}
|
||||
|
||||
setAllPages([...defaultPages, ...response].filter(shouldShowPage));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchedText) {
|
||||
setSearchResults(defaultPages.filter(shouldShowPage));
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSearchText = searchedText.trim().toLowerCase();
|
||||
getAllPages().then((unfilteredPages = defaultPages) => {
|
||||
const filteredPages = unfilteredPages
|
||||
.filter((currPage: PageType) => {
|
||||
return (
|
||||
currPage.title.toLowerCase().indexOf(normalizedSearchText) !== -1
|
||||
);
|
||||
})
|
||||
.slice(0, 10);
|
||||
|
||||
setActiveCounter(0);
|
||||
setSearchResults(filteredPages);
|
||||
});
|
||||
}, [searchedText]);
|
||||
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 flex h-full justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div className="relative top-0 h-full w-full max-w-lg p-2 sm:top-20 md:h-auto">
|
||||
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autofocus={true}
|
||||
type="text"
|
||||
value={searchedText}
|
||||
className="w-full rounded-t-md border-b p-4 text-sm focus:bg-gray-50 focus:outline-none"
|
||||
placeholder="Search roadmaps, guides or pages .."
|
||||
autocomplete="off"
|
||||
onInput={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
setSearchedText(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
const canGoNext = activeCounter < searchResults.length - 1;
|
||||
setActiveCounter(canGoNext ? activeCounter + 1 : 0);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
const canGoPrev = activeCounter > 0;
|
||||
setActiveCounter(
|
||||
canGoPrev ? activeCounter - 1 : searchResults.length - 1
|
||||
);
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Escape') {
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
const activePage = searchResults[activeCounter];
|
||||
if (activePage) {
|
||||
window.location.href = activePage.url;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="px-2 py-2">
|
||||
<div className="flex flex-col">
|
||||
{searchResults.length === 0 && (
|
||||
<div class="p-5 text-center text-sm text-gray-400">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.map((page, counter) => {
|
||||
const prevPage = searchResults[counter - 1];
|
||||
const groupChanged = prevPage && prevPage.group !== page.group;
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupChanged && (
|
||||
<div class="border-b border-gray-100"></div>
|
||||
)}
|
||||
<a
|
||||
class={`flex w-full items-center rounded p-2 text-sm ${
|
||||
counter === activeCounter ? 'bg-gray-100' : ''
|
||||
}`}
|
||||
onMouseOver={() => setActiveCounter(counter)}
|
||||
href={page.url}
|
||||
>
|
||||
{!page.icon && (
|
||||
<span class="mr-2 text-gray-400">{page.group}</span>
|
||||
)}
|
||||
{page.icon && (
|
||||
<img alt={page.title} src={page.icon} class="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{page.title}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
src/components/CreateTeam/CreateTeamForm.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Stepper } from '../Stepper';
|
||||
import { Step0, ValidTeamType } from './Step0';
|
||||
import { Step1, ValidTeamSize } from './Step1';
|
||||
import { Step2 } from './Step2';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import { Step3 } from './Step3';
|
||||
import { Step4 } from './Step4';
|
||||
import {useToast} from "../../hooks/use-toast";
|
||||
|
||||
export interface TeamDocument {
|
||||
_id?: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
creatorId: string;
|
||||
links: {
|
||||
website?: string;
|
||||
github?: string;
|
||||
linkedIn?: string;
|
||||
};
|
||||
type: ValidTeamType;
|
||||
canMemberSendInvite: boolean;
|
||||
teamSize?: ValidTeamSize;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export function CreateTeamForm() {
|
||||
// Can't use hook `useParams` because it runs asynchronously
|
||||
const { s: queryStepIndex, t: teamId } = getUrlParams();
|
||||
|
||||
const toast = useToast();
|
||||
const [team, setTeam] = useState<TeamDocument>();
|
||||
|
||||
const [loadingTeam, setLoadingTeam] = useState(!!teamId && !team?._id);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
|
||||
async function loadTeam(
|
||||
teamIdToFetch: string,
|
||||
requiredStepIndex: number | string
|
||||
) {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error loading team');
|
||||
window.location.href = '/account';
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredStepIndexNumber = parseInt(requiredStepIndex as string, 10);
|
||||
const completedSteps = Array(requiredStepIndexNumber)
|
||||
.fill(1)
|
||||
.map((_, counter) => counter);
|
||||
|
||||
setTeam(response);
|
||||
setSelectedTeamType(response.type);
|
||||
setCompletedSteps(completedSteps);
|
||||
setStepIndex(requiredStepIndexNumber);
|
||||
|
||||
await loadTeamResourceConfig(teamIdToFetch);
|
||||
}
|
||||
|
||||
const [teamResourceConfig, setTeamResourceConfig] =
|
||||
useState<TeamResourceConfig>([]);
|
||||
|
||||
async function loadTeamResourceConfig(teamId: string) {
|
||||
const { error, response } = await httpGet<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
|
||||
);
|
||||
if (error || !Array.isArray(response)) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId || !queryStepIndex || team) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('Fetching team');
|
||||
setLoadingTeam(true);
|
||||
loadTeam(teamId, queryStepIndex).finally(() => {
|
||||
setLoadingTeam(false);
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
|
||||
// fetch team and move to step
|
||||
}, [teamId, queryStepIndex]);
|
||||
|
||||
const [selectedTeamType, setSelectedTeamType] = useState<ValidTeamType>(
|
||||
team?.type || 'company'
|
||||
);
|
||||
|
||||
const [completedSteps, setCompletedSteps] = useState([0]);
|
||||
if (loadingTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let stepForm = null;
|
||||
if (stepIndex === 0) {
|
||||
stepForm = (
|
||||
<Step0
|
||||
team={team}
|
||||
selectedTeamType={selectedTeamType}
|
||||
setSelectedTeamType={setSelectedTeamType}
|
||||
onStepComplete={() => {
|
||||
if (team?._id) {
|
||||
setUrlParams({ t: team._id, s: '1' });
|
||||
}
|
||||
|
||||
setCompletedSteps([0]);
|
||||
setStepIndex(1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 1) {
|
||||
stepForm = (
|
||||
<Step1
|
||||
team={team}
|
||||
onBack={() => {
|
||||
if (team?._id) {
|
||||
setUrlParams({ t: team._id, s: '0' });
|
||||
}
|
||||
|
||||
setStepIndex(0);
|
||||
}}
|
||||
onStepComplete={(team: TeamDocument) => {
|
||||
const createdTeamId = team._id!;
|
||||
|
||||
setUrlParams({ t: createdTeamId, s: '2' });
|
||||
|
||||
setCompletedSteps([0, 1]);
|
||||
setStepIndex(2);
|
||||
setTeam(team);
|
||||
}}
|
||||
selectedTeamType={selectedTeamType}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 2) {
|
||||
stepForm = (
|
||||
<Step2
|
||||
team={team!}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
onBack={() => {
|
||||
if (team) {
|
||||
setUrlParams({ t: team._id!, s: '1' });
|
||||
}
|
||||
|
||||
setStepIndex(1);
|
||||
}}
|
||||
onNext={() => {
|
||||
setUrlParams({ t: teamId!, s: '3' });
|
||||
setCompletedSteps([0, 1, 2]);
|
||||
setStepIndex(3);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 3) {
|
||||
stepForm = (
|
||||
<Step3
|
||||
team={team}
|
||||
onBack={() => {
|
||||
if (team) {
|
||||
setUrlParams({ t: team._id!, s: '2' });
|
||||
}
|
||||
|
||||
setStepIndex(2);
|
||||
}}
|
||||
onNext={() => {
|
||||
if (team) {
|
||||
setUrlParams({ t: team._id!, s: '4' });
|
||||
}
|
||||
|
||||
setCompletedSteps([0, 1, 2, 3]);
|
||||
setStepIndex(4);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 4) {
|
||||
stepForm = <Step4 team={team!} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'mx-auto max-w-[700px] py-6'}>
|
||||
<div className={'mb-8 flex flex-col items-center'}>
|
||||
<h1 className={'text-4xl font-bold'}>Create Team</h1>
|
||||
<p className={'mt-2 text-gray-500'}>
|
||||
Complete the steps below to create your team
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-8 mt-8 flex w-full">
|
||||
<Stepper
|
||||
activeIndex={stepIndex}
|
||||
completeSteps={completedSteps}
|
||||
steps={[
|
||||
{ label: 'Type' },
|
||||
{ label: 'Details' },
|
||||
{ label: 'Skills' },
|
||||
{ label: 'Members' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stepForm}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/CreateTeam/NextButton.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
|
||||
type NextButtonProps = {
|
||||
isLoading?: boolean;
|
||||
loadingMessage?: string;
|
||||
text: string;
|
||||
hasNextArrow?: boolean;
|
||||
onClick?: () => void;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export function NextButton(props: NextButtonProps) {
|
||||
const {
|
||||
isLoading = false,
|
||||
text = 'Next Step',
|
||||
type = 'button',
|
||||
loadingMessage = 'Please wait ..',
|
||||
onClick = () => null,
|
||||
hasNextArrow = true,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className={
|
||||
'rounded-md border border-black bg-black px-4 py-2 text-white disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className={'flex items-center justify-center'}>
|
||||
<Spinner />
|
||||
<span className="ml-2">{loadingMessage}</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{text}
|
||||
{hasNextArrow && <span className="ml-1">→</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
221
src/components/CreateTeam/RoadmapSelector.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { SearchSelector } from '../SearchSelector';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import SearchIcon from '../../icons/search.svg';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
|
||||
|
||||
export type TeamResourceConfig = {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
removed: string[];
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
team: TeamDocument;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
const { team, teamResourceConfig = [], setTeamResourceConfig } = props;
|
||||
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
async function loadAllRoadmaps() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
setError(error.message || 'Something went wrong. Please try again!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allRoadmaps = response
|
||||
.filter((page) => page.group === 'Roadmaps')
|
||||
.sort((a, b) => {
|
||||
if (a.title === 'Android') return 1;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
setAllRoadmaps(allRoadmaps);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function deleteResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Deleting resource`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Error deleting roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
pageProgressMessage.set('Removing roadmap');
|
||||
|
||||
deleteResource(resourceId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
async function addTeamResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Adding roadmap to team`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
{
|
||||
teamId: team._id,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAllRoadmaps().finally();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{changingRoadmapId && (
|
||||
<UpdateTeamResourceModal
|
||||
onClose={() => setChangingRoadmapId('')}
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={team?._id!}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
defaultRemovedItems={
|
||||
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SearchSelector
|
||||
placeholder={`Search Roadmaps ..`}
|
||||
onSelect={(option) => {
|
||||
const roadmapId = option.value;
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
options={allRoadmaps
|
||||
.filter((roadmap) => {
|
||||
return !teamResourceConfig
|
||||
.map((c) => c.resourceId)
|
||||
.includes(roadmap.id);
|
||||
})
|
||||
.map((roadmap) => ({
|
||||
value: roadmap.id,
|
||||
label: roadmap.title,
|
||||
}))}
|
||||
searchInputId={'roadmap-input'}
|
||||
inputClassName="mt-2 block w-full rounded-md border px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
/>
|
||||
|
||||
{!teamResourceConfig.length && (
|
||||
<div className="mt-4 rounded-md border px-4 py-12 text-center text-sm text-gray-700">
|
||||
<img
|
||||
alt={'search'}
|
||||
src={SearchIcon}
|
||||
className={'mx-auto mb-5 h-[42px] w-[42px] opacity-10'}
|
||||
/>
|
||||
<span className="block text-lg font-semibold text-black">
|
||||
No roadmaps selected.
|
||||
</span>
|
||||
<p className={'text-sm text-gray-400'}>
|
||||
Please search and add roadmaps from above
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teamResourceConfig.length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-3 flex-wrap gap-2.5">
|
||||
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
|
||||
const roadmapTitle =
|
||||
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
|
||||
'...';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start rounded-md border border-gray-300">
|
||||
<div className={'w-full px-3 pb-2 pt-4'}>
|
||||
<span className="mb-0.5 block text-base font-medium leading-none text-black">
|
||||
{roadmapTitle}
|
||||
</span>
|
||||
{removedTopics.length > 0 ? (
|
||||
<span className={'text-xs leading-none text-gray-900'}>
|
||||
{removedTopics.length} topic
|
||||
{removedTopics.length > 1 ? 's' : ''} removed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-none text-gray-400/60">
|
||||
No changes made ..
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={'flex w-full justify-between p-3'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
|
||||
}
|
||||
onClick={() => setChangingRoadmapId(resourceId)}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black'
|
||||
}
|
||||
onClick={() => onRemove(resourceId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/components/CreateTeam/RoleDropdown.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
|
||||
const allowedRoles = [
|
||||
{
|
||||
name: 'Admin',
|
||||
value: 'admin',
|
||||
description: 'Can do everything',
|
||||
},
|
||||
{
|
||||
name: 'Manager',
|
||||
value: 'manager',
|
||||
description: 'Can manage team and skills',
|
||||
},
|
||||
{
|
||||
name: 'Member',
|
||||
value: 'member',
|
||||
description: 'Can view team and skills',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type AllowedRoles = (typeof allowedRoles)[number]['value'];
|
||||
|
||||
type RoleDropdownProps = {
|
||||
className?: string;
|
||||
selectedRole: string;
|
||||
setSelectedRole: (role: AllowedRoles) => void;
|
||||
};
|
||||
|
||||
export function RoleDropdown(props: RoleDropdownProps) {
|
||||
const { selectedRole, setSelectedRole, className = 'w-[120px]' } = props;
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const [activeRoleIndex, setActiveRoleIndex] = useState(0);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setIsMenuOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
type={'button'}
|
||||
onKeyDown={(e) => {
|
||||
const isUpOrDown = e.key === 'ArrowUp' || e.key === 'ArrowDown';
|
||||
if (isUpOrDown && !isMenuOpen) {
|
||||
e.preventDefault();
|
||||
setIsMenuOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const isEnter = e.key === 'Enter';
|
||||
if (isEnter && isMenuOpen) {
|
||||
e.preventDefault();
|
||||
setSelectedRole(allowedRoles[activeRoleIndex].value);
|
||||
setIsMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveRoleIndex((prev) => {
|
||||
const nextIndex = prev + 1;
|
||||
if (nextIndex >= allowedRoles.length) {
|
||||
return 0;
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveRoleIndex((prev) => {
|
||||
const nextIndex = prev - 1;
|
||||
if (nextIndex < 0) {
|
||||
return allowedRoles.length - 1;
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className={`flex h-full w-full cursor-default items-center justify-between rounded-md border px-4 ${
|
||||
isMenuOpen ? 'border-gray-300 bg-gray-100' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`capitalize ${
|
||||
selectedRole === 'admin' ? 'text-blue-600' : ''
|
||||
} ${selectedRole === 'manager' ? 'text-cyan-600' : ''}`}
|
||||
>
|
||||
{selectedRole || 'Select Role'}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={'relative top-0.5 ml-2 h-4 w-4 text-gray-400'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
className="absolute z-10 mt-1 w-[200px] rounded-md border bg-white shadow-md"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<div
|
||||
className="py-1"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
{allowedRoles.map((allowedRole, roleCounter) => (
|
||||
<button
|
||||
key={allowedRole.value}
|
||||
type={'button'}
|
||||
className={`w-full cursor-default px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 ${
|
||||
roleCounter === activeRoleIndex ? 'bg-gray-100' : 'bg-white'
|
||||
}`}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
setSelectedRole(allowedRole.value);
|
||||
}}
|
||||
>
|
||||
<span className="block font-medium">{allowedRole.name}</span>
|
||||
<span className="block text-xs text-gray-400">
|
||||
{allowedRole.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/components/CreateTeam/Step0.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import BuildingIcon from '../../icons/building.svg';
|
||||
import UsersIcon from '../../icons/users.svg';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { httpPut } from '../../lib/http';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { NextButton } from './NextButton';
|
||||
|
||||
export const validTeamTypes = [
|
||||
{
|
||||
value: 'company',
|
||||
label: 'Company',
|
||||
icon: BuildingIcon,
|
||||
description: 'Use roadmap.sh for your company',
|
||||
},
|
||||
{
|
||||
value: 'study_group',
|
||||
label: 'Study Group',
|
||||
icon: UsersIcon,
|
||||
description: 'Invite your friends and learn together',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ValidTeamType = (typeof validTeamTypes)[number]['value'];
|
||||
|
||||
type Step0Props = {
|
||||
team?: TeamDocument;
|
||||
selectedTeamType: ValidTeamType;
|
||||
setSelectedTeamType: (teamType: ValidTeamType) => void;
|
||||
onStepComplete: () => void;
|
||||
};
|
||||
|
||||
export function Step0(props: Step0Props) {
|
||||
const { team, selectedTeamType, onStepComplete, setSelectedTeamType } = props;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
async function onNextClick() {
|
||||
if (!team) {
|
||||
onStepComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPut(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${team._id}`,
|
||||
{
|
||||
name: team.name,
|
||||
website: team?.links?.website || undefined,
|
||||
type: selectedTeamType,
|
||||
gitHubUrl: team?.links?.github || undefined,
|
||||
...(selectedTeamType === 'company' && {
|
||||
teamSize: team.teamSize,
|
||||
linkedInUrl: team?.links?.linkedIn || undefined,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
onStepComplete();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-row gap-3'}>
|
||||
{validTeamTypes.map((validTeamType) => (
|
||||
<button
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 py-12 ${
|
||||
validTeamType.value == selectedTeamType
|
||||
? 'border-gray-400 bg-gray-100'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedTeamType(validTeamType.value)}
|
||||
>
|
||||
<img
|
||||
alt={validTeamType.label}
|
||||
src={validTeamType.icon}
|
||||
className={`mb-3 h-12 w-12 opacity-10 ${
|
||||
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className="mb-1 block text-2xl font-bold">
|
||||
{validTeamType.label}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{validTeamType.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*Error message*/}
|
||||
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<a
|
||||
href="/account"
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-8 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<NextButton
|
||||
type={'button'}
|
||||
onClick={onNextClick}
|
||||
isLoading={isLoading}
|
||||
text={'Next Step'}
|
||||
loadingMessage={'Updating team ..'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
252
src/components/CreateTeam/Step1.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { AppError, httpPost, httpPut } from '../../lib/http';
|
||||
import type { ValidTeamType } from './Step0';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
|
||||
export const validTeamSizes = [
|
||||
'0-1',
|
||||
'2-10',
|
||||
'11-50',
|
||||
'51-200',
|
||||
'201-500',
|
||||
'501-1000',
|
||||
'1000+',
|
||||
] as const;
|
||||
|
||||
export type ValidTeamSize = (typeof validTeamSizes)[number];
|
||||
|
||||
type Step1Props = {
|
||||
team?: TeamDocument;
|
||||
selectedTeamType: ValidTeamType;
|
||||
onStepComplete: (team: TeamDocument) => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function Step1(props: Step1Props) {
|
||||
const { team, selectedTeamType, onBack, onStepComplete } = props;
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const nameRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nameRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
nameRef.current.focus();
|
||||
}, [nameRef]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [name, setName] = useState(team?.name || '');
|
||||
const [website, setWebsite] = useState(team?.links?.website || '');
|
||||
const [linkedInUrl, setLinkedInUrl] = useState(team?.links?.linkedIn || '');
|
||||
const [gitHubUrl, setGitHubUrl] = useState(team?.links?.github || '');
|
||||
const [teamSize, setTeamSize] = useState<ValidTeamSize>(
|
||||
team?.teamSize || ('' as any)
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
if (!name || !selectedTeamType) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let response: TeamDocument | undefined;
|
||||
let error: AppError | undefined;
|
||||
|
||||
if (!team?._id) {
|
||||
({ response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-create-team`,
|
||||
{
|
||||
name,
|
||||
website: website || undefined,
|
||||
type: selectedTeamType,
|
||||
gitHubUrl: gitHubUrl || undefined,
|
||||
...(selectedTeamType === 'company' && {
|
||||
teamSize,
|
||||
linkedInUrl: linkedInUrl || undefined,
|
||||
}),
|
||||
roadmapIds: [],
|
||||
bestPracticeIds: [],
|
||||
}
|
||||
));
|
||||
|
||||
if (error || !response?._id) {
|
||||
setError(error?.message || 'Something went wrong. Please try again.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onStepComplete(response as TeamDocument);
|
||||
} else {
|
||||
({ response, error } = await httpPut(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${team._id}`,
|
||||
{
|
||||
name,
|
||||
website: website || undefined,
|
||||
type: selectedTeamType,
|
||||
gitHubUrl: gitHubUrl || undefined,
|
||||
...(selectedTeamType === 'company' && {
|
||||
teamSize,
|
||||
linkedInUrl: linkedInUrl || undefined,
|
||||
}),
|
||||
}
|
||||
));
|
||||
|
||||
if (error || (response as any)?.status !== 'ok') {
|
||||
setError(error?.message || 'Something went wrong. Please try again.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onStepComplete({
|
||||
...team,
|
||||
name,
|
||||
_id: team._id,
|
||||
links: {
|
||||
website: website || team?.links?.website,
|
||||
linkedIn: linkedInUrl || team?.links?.linkedIn,
|
||||
github: gitHubUrl || team?.links?.github,
|
||||
},
|
||||
type: selectedTeamType,
|
||||
teamSize: teamSize!,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="name"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
{selectedTeamType === 'company' ? 'Company Name' : 'Group Name'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
ref={nameRef as any}
|
||||
autofocus={true}
|
||||
id="name"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="roadmap.sh"
|
||||
disabled={isLoading}
|
||||
required
|
||||
value={name}
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="website"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
required
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://roadmap.sh"
|
||||
disabled={isLoading}
|
||||
value={website}
|
||||
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
LinkedIn URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://www.linkedin.com/company/roadmapsh"
|
||||
disabled={isLoading}
|
||||
value={linkedInUrl}
|
||||
onInput={(e) =>
|
||||
setLinkedInUrl((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
GitHub Organization URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://github.com/roadmapsh"
|
||||
disabled={isLoading}
|
||||
value={gitHubUrl}
|
||||
onInput={(e) => setGitHubUrl((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="team-size"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Company Size
|
||||
</label>
|
||||
<select
|
||||
name="team-size"
|
||||
id="team-size"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required={selectedTeamType === 'company'}
|
||||
disabled={isLoading}
|
||||
value={teamSize}
|
||||
onChange={(e) =>
|
||||
setTeamSize((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="" selected>
|
||||
Select team size
|
||||
</option>
|
||||
{validTeamSizes.map((size) => (
|
||||
<option value={size}>{size} people</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
<span className="mr-1">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
<NextButton
|
||||
isLoading={isLoading}
|
||||
text={'Next Step'}
|
||||
type={'submit'}
|
||||
loadingMessage={'Creating team ..'}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
59
src/components/CreateTeam/Step2.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { RoadmapSelector, TeamResourceConfig } from './RoadmapSelector';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
|
||||
type Step2Props = {
|
||||
team: TeamDocument;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
|
||||
export function Step2(props: Step2Props) {
|
||||
const { team, onBack, onNext, teamResourceConfig, setTeamResourceConfig } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<div className="mb-1 mt-2">
|
||||
<h2 className="mb-2 text-2xl font-bold">Select Roadmaps</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
Picks the roadmaps to be made available to your team for tracking.
|
||||
You can always add more later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RoadmapSelector
|
||||
team={team}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
<span className="mr-1">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={teamResourceConfig.length === 0}
|
||||
onClick={onNext}
|
||||
className={
|
||||
'rounded-md border bg-black px-4 py-2 text-white disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
Next Step
|
||||
<span className="ml-1">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
198
src/components/CreateTeam/Step3.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
import { TrashIcon } from '../ReactIcons/TrashIcon';
|
||||
import { AllowedRoles, RoleDropdown } from './RoleDropdown';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
type Step3Props = {
|
||||
team?: TeamDocument;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
type InviteType = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: AllowedRoles;
|
||||
};
|
||||
|
||||
function generateId() {
|
||||
return `${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
export function Step3(props: Step3Props) {
|
||||
const { onNext, onBack, team } = props;
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [invitingTeam, setInvitingTeam] = useState(false);
|
||||
const emailInputRef = useRef(null);
|
||||
|
||||
const [users, setUsers] = useState<InviteType[]>([
|
||||
{
|
||||
id: generateId(),
|
||||
email: '',
|
||||
role: 'member',
|
||||
},
|
||||
]);
|
||||
|
||||
async function inviteTeam() {
|
||||
setInvitingTeam(true);
|
||||
const { error, response } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-invite-team/${team?._id}`,
|
||||
{
|
||||
members: users,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
setInvitingTeam(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onNext();
|
||||
}
|
||||
|
||||
function focusLastEmailInput() {
|
||||
if (!emailInputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
(emailInputRef.current as HTMLInputElement).focus();
|
||||
}
|
||||
|
||||
function onSubmit(e: any) {
|
||||
e.preventDefault();
|
||||
|
||||
inviteTeam().finally(() => null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
focusLastEmailInput();
|
||||
}, [users.length]);
|
||||
|
||||
return (
|
||||
<form className="mt-4 flex w-full flex-col" onSubmit={onSubmit}>
|
||||
<div class="mb-1 mt-2">
|
||||
<h2 class="mb-2 text-2xl font-bold">Invite your Team</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
Use the form below to invite your team members to your team. You can
|
||||
also invite them later.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-1">
|
||||
{users.map((user, userCounter) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-2" key={user.id}>
|
||||
<input
|
||||
ref={userCounter === users.length - 1 ? emailInputRef : null}
|
||||
autofocus={true}
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
value={user.email}
|
||||
onChange={(e) => {
|
||||
const newUsers = users.map((u) => {
|
||||
if (u.id === user.id) {
|
||||
return {
|
||||
...u,
|
||||
email: (e.target as HTMLInputElement)?.value,
|
||||
};
|
||||
}
|
||||
|
||||
return u;
|
||||
});
|
||||
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
className="flex-grow rounded-md border border-gray-200 bg-white px-4 py-2 text-gray-900"
|
||||
/>
|
||||
<RoleDropdown
|
||||
selectedRole={user.role}
|
||||
setSelectedRole={(role: AllowedRoles) => {
|
||||
const newUsers = users.map((u) => {
|
||||
if (u.id === user.id) {
|
||||
return {
|
||||
...u,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
return u;
|
||||
});
|
||||
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
disabled={users.length <= 1}
|
||||
type="button"
|
||||
className="rounded-md border border-red-200 bg-white px-4 py-2 text-red-500 hover:bg-red-100 disabled:opacity-30"
|
||||
onClick={() => {
|
||||
setUsers(users.filter((u) => u.id !== user.id));
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{users.length <= 30 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setUsers([
|
||||
...users,
|
||||
{ id: generateId(), email: '', role: 'member' },
|
||||
]);
|
||||
}}
|
||||
type="button"
|
||||
className="mt-2 rounded-md border border-dashed border-gray-400 py-2 text-sm text-gray-500 hover:border-gray-500 hover:text-gray-800"
|
||||
>
|
||||
+ Add another
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-2 text-sm font-medium text-red-500" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
<span className="mr-1">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
<div className={'flex gap-2'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
className={
|
||||
'rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black'
|
||||
}
|
||||
>
|
||||
Skip for Now
|
||||
</button>
|
||||
<NextButton
|
||||
type={'submit'}
|
||||
isLoading={invitingTeam}
|
||||
text={'Send Invites'}
|
||||
loadingMessage={'Updating team ..'}
|
||||
hasNextArrow={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
26
src/components/CreateTeam/Step4.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
|
||||
type Step4Props = {
|
||||
team: TeamDocument;
|
||||
};
|
||||
|
||||
export function Step4({ team }: Step4Props) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-col rounded-xl border py-12 text-center">
|
||||
<div class="mb-1 flex flex-col items-center">
|
||||
<CheckIcon additionalClasses={'h-14 w-14 mb-4 opacity-100'} />
|
||||
<h2 class="mb-2 text-2xl font-bold">Team Created</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
Your team has been created. Happy learning!
|
||||
</p>
|
||||
<a
|
||||
href={`/team/progress?t=${team._id}`}
|
||||
class="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
|
||||
>
|
||||
View Team
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/components/CreateTeam/UpdateTeamResourceModal.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||
import '../FrameRenderer/FrameRenderer.css';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
teamId: string;
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
defaultRemovedItems?: string[];
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
const {
|
||||
defaultRemovedItems = [],
|
||||
resourceId,
|
||||
resourceType,
|
||||
teamId,
|
||||
setTeamResourceConfig,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const containerEl = useRef<HTMLDivElement>(null);
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const [removedItems, setRemovedItems] =
|
||||
useState<string[]>(defaultRemovedItems);
|
||||
|
||||
useEffect(() => {
|
||||
function onTopicClick(e: any) {
|
||||
const groupEl = e.target.closest('.clickable-group');
|
||||
const groupId = groupEl?.dataset?.groupId;
|
||||
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
if (removedItems.includes(normalizedGroupId)) {
|
||||
setRemovedItems((prev) =>
|
||||
prev.filter((id) => id !== normalizedGroupId)
|
||||
);
|
||||
renderTopicProgress(normalizedGroupId, 'reset' as any);
|
||||
} else {
|
||||
setRemovedItems((prev) => [...prev, normalizedGroupId]);
|
||||
renderTopicProgress(normalizedGroupId, 'removed');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', onTopicClick);
|
||||
return () => {
|
||||
document.removeEventListener('click', onTopicClick);
|
||||
};
|
||||
}, [removedItems]);
|
||||
|
||||
let resourceJsonUrl = 'https://roadmap.sh';
|
||||
if (resourceType === 'roadmap') {
|
||||
resourceJsonUrl += `/${resourceId}.json`;
|
||||
} else {
|
||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||
}
|
||||
|
||||
async function renderResource(jsonUrl: string) {
|
||||
const res = await fetch(jsonUrl);
|
||||
const json = await res.json();
|
||||
const svg = await wireframeJSONToSVG(json, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
|
||||
containerEl.current?.replaceChildren(svg);
|
||||
|
||||
// Render team configuration
|
||||
removedItems.forEach((topicId: string) => {
|
||||
renderTopicProgress(topicId, 'removed');
|
||||
});
|
||||
}
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
async function onSaveChanges() {
|
||||
if (removedItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: resourceId,
|
||||
resourceType: resourceType,
|
||||
removed: removedItems,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
onClose();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!containerEl.current ||
|
||||
!resourceJsonUrl ||
|
||||
!resourceId ||
|
||||
!resourceType ||
|
||||
!teamId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderResource(resourceJsonUrl)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('Something went wrong. Please try again!');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div class="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div class="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative rounded-lg bg-white shadow"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'sticky top-0 mb-3 rounded-2xl border-4 border-white bg-black p-4'
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-gray-300">
|
||||
Click and select the items to remove from the roadmap.
|
||||
</p>
|
||||
<div className="flex flex-row items-center gap-1.5">
|
||||
<button
|
||||
disabled={removedItems.length === 0}
|
||||
onClick={() =>
|
||||
onSaveChanges().finally(() => setIsUpdating(false))
|
||||
}
|
||||
className={
|
||||
'rounded-md bg-blue-600 px-2.5 py-1.5 text-sm text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400'
|
||||
}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<span className={'flex items-center gap-1.5'}>
|
||||
<Spinner
|
||||
className="h-3 w-3"
|
||||
innerFill="white"
|
||||
isDualRing={false}
|
||||
/>{' '}
|
||||
Saving ..
|
||||
</span>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md bg-gray-600 px-2.5 py-1.5 text-sm text-white hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerEl} className="px-4"></div>
|
||||
|
||||
{isLoading && (
|
||||
<div class="flex w-full justify-center">
|
||||
<Spinner
|
||||
isDualRing={false}
|
||||
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/components/DeleteAccount/DeleteAccount.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import DeleteAccountPopup from "./DeleteAccountPopup.astro";
|
||||
---
|
||||
<DeleteAccountPopup />
|
||||
|
||||
<h2 class='text-xl font-bold sm:text-2xl'>Delete Account</h2>
|
||||
<p class='mt-2 text-gray-400'>
|
||||
Permanently remove your account from the roadmap.sh. This cannot be undone and all your progress and data will be lost.
|
||||
</p>
|
||||
|
||||
<button
|
||||
data-popup='delete-account-popup'
|
||||
class="mt-4 w-full rounded-lg bg-red-600 py-2 text-base font-regular text-white outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
89
src/components/DeleteAccount/DeleteAccountForm.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import {useEffect, useState} from 'preact/hooks';
|
||||
import { httpDelete } from '../../lib/http';
|
||||
import { logout } from '../Navigation/navigation';
|
||||
|
||||
export function DeleteAccountForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [confirmationText, setConfirmationText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setError('');
|
||||
setConfirmationText('');
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
if (confirmationText.toUpperCase() !== 'DELETE') {
|
||||
setError('Verification text does not match');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpDelete(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-account`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
logout();
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
setConfirmationText('');
|
||||
|
||||
const deleteAccountPopup = document.getElementById('delete-account-popup');
|
||||
deleteAccountPopup?.classList.add('hidden');
|
||||
deleteAccountPopup?.classList.remove('flex');
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="my-4">
|
||||
<input
|
||||
type="text"
|
||||
name="delete-account"
|
||||
id="delete-account"
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 py-2 px-3 outline-none placeholder:text-gray-400 focus:border-gray-400"
|
||||
placeholder={'Type "delete" to confirm'}
|
||||
required
|
||||
autoFocus
|
||||
value={confirmationText}
|
||||
onInput={(e) =>
|
||||
setConfirmationText((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={handleClosePopup}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || confirmationText.toUpperCase() !== 'DELETE'}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? 'Please wait ..' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||