Compare commits
617 Commits
fix/activi
...
fix/userna
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159bbd903f | ||
|
|
48b636b145 | ||
|
|
c8e968949e | ||
|
|
26967da40b | ||
|
|
7e09d54a65 | ||
|
|
0b47cfc981 | ||
|
|
b7daa93f7c | ||
|
|
de624e1967 | ||
|
|
bcac605aeb | ||
|
|
f16aa78829 | ||
|
|
1330e5c4b9 | ||
|
|
a4b0a72c37 | ||
|
|
680b2241e8 | ||
|
|
b48f81d98d | ||
|
|
f179033dd3 | ||
|
|
853c228623 | ||
|
|
cebb561afe | ||
|
|
d1a698447d | ||
|
|
830aae4d9c | ||
|
|
f1a34b3997 | ||
|
|
92b519396d | ||
|
|
e04712aa2d | ||
|
|
7ac388e51c | ||
|
|
9ddda3a255 | ||
|
|
64e2e43b82 | ||
|
|
6ec8d2a29b | ||
|
|
9ec6541ad7 | ||
|
|
c190bdb6b2 | ||
|
|
f016fdbb72 | ||
|
|
10a5268a9f | ||
|
|
f08c7d5052 | ||
|
|
41109ecd90 | ||
|
|
fa3a3adc65 | ||
|
|
f4c2616b88 | ||
|
|
dadaa18687 | ||
|
|
3c065338db | ||
|
|
cd057508cb | ||
|
|
366bd61562 | ||
|
|
9154a57eb9 | ||
|
|
24f9e0c6ce | ||
|
|
8b82746676 | ||
|
|
d09962b6a3 | ||
|
|
df3dfe9971 | ||
|
|
ec175482bd | ||
|
|
5aa67c2e2b | ||
|
|
22290ae0b7 | ||
|
|
a8f68371f0 | ||
|
|
0da2cab0ab | ||
|
|
bab0ec0a5d | ||
|
|
36b42dfaa2 | ||
|
|
6cd18458db | ||
|
|
93eb568bbd | ||
|
|
3997641d0b | ||
|
|
3fda008f12 | ||
|
|
7f1f58516e | ||
|
|
afb0da4bd6 | ||
|
|
485b3d5c9a | ||
|
|
78e20d1e85 | ||
|
|
e7cd703607 | ||
|
|
01c78a8cf4 | ||
|
|
cc123f74ea | ||
|
|
fed5f722b9 | ||
|
|
cb4b5a4cc9 | ||
|
|
38be5892d3 | ||
|
|
24b47d3dd7 | ||
|
|
783e2400b7 | ||
|
|
c9390d8612 | ||
|
|
0cad5890ea | ||
|
|
f2297389a7 | ||
|
|
68906c6cf6 | ||
|
|
d5ea2ed17a | ||
|
|
6118162b03 | ||
|
|
0a675760ed | ||
|
|
4b5635c5e5 | ||
|
|
ee298f9959 | ||
|
|
d09710fee6 | ||
|
|
7d3d022d5a | ||
|
|
e81571f7fc | ||
|
|
ed01ffbefa | ||
|
|
1e5b467124 | ||
|
|
03b6337388 | ||
|
|
9aed682629 | ||
|
|
1c515f1d8f | ||
|
|
1ebf850882 | ||
|
|
b7b8a935c1 | ||
|
|
3cf0a7ca8a | ||
|
|
fac090c803 | ||
|
|
adc44ed325 | ||
|
|
2c79d85c67 | ||
|
|
e24f5dfe6a | ||
|
|
ad712b2c4a | ||
|
|
f3fda96c15 | ||
|
|
db1ba63e6c | ||
|
|
f63c59d9ee | ||
|
|
72cc28a436 | ||
|
|
58e2405fa0 | ||
|
|
e5ee35acee | ||
|
|
a347c1739b | ||
|
|
10ac77308d | ||
|
|
de6aaa262b | ||
|
|
1fe5512310 | ||
|
|
96b8e109b1 | ||
|
|
64e71574d2 | ||
|
|
5913564d94 | ||
|
|
6686e9361c | ||
|
|
e738936b5e | ||
|
|
b97e2c7ce1 | ||
|
|
3e312b6aa7 | ||
|
|
e8a430db47 | ||
|
|
47e6f8e926 | ||
|
|
fa6f4aa6e3 | ||
|
|
cf0d10eeed | ||
|
|
38d96682cf | ||
|
|
61788edcd0 | ||
|
|
c48907c5e0 | ||
|
|
90371b081a | ||
|
|
c80591c1cf | ||
|
|
4734a8eb02 | ||
|
|
b6ceebae9c | ||
|
|
54459a52f2 | ||
|
|
446373532f | ||
|
|
a69459ba31 | ||
|
|
7f35c2f6f0 | ||
|
|
7e2f9d3e6b | ||
|
|
e4d106904e | ||
|
|
7d694f3e56 | ||
|
|
338bce1308 | ||
|
|
c9d6b36b34 | ||
|
|
2874eb0a42 | ||
|
|
a62ed919c1 | ||
|
|
9ecf4a9d78 | ||
|
|
2c373c7574 | ||
|
|
d9cdc95a79 | ||
|
|
3af4bde2ea | ||
|
|
1ee6f0e125 | ||
|
|
9471bf50f9 | ||
|
|
f143d800bd | ||
|
|
f7b42a63bf | ||
|
|
212be69582 | ||
|
|
393eb6c87d | ||
|
|
fe6e0830eb | ||
|
|
24c4221591 | ||
|
|
7744363cde | ||
|
|
ce6e2ff71e | ||
|
|
09e345f48b | ||
|
|
5dff9b20e1 | ||
|
|
f1d6cd51cd | ||
|
|
045bab002a | ||
|
|
08b1b48b5e | ||
|
|
0b6da0e076 | ||
|
|
520fa2db45 | ||
|
|
3c160e8809 | ||
|
|
f682a6e1a2 | ||
|
|
3f655ad424 | ||
|
|
5b108f1fd2 | ||
|
|
0064d04ff4 | ||
|
|
e98ebcfa11 | ||
|
|
64bbbc2f25 | ||
|
|
2da1f61945 | ||
|
|
894b66f026 | ||
|
|
f5fc71aadb | ||
|
|
ec9bebbcda | ||
|
|
9cf940e741 | ||
|
|
f4b157b328 | ||
|
|
4c54e20a11 | ||
|
|
c4cc0630c0 | ||
|
|
a637805a24 | ||
|
|
8604810a2e | ||
|
|
a2481f7681 | ||
|
|
88926c9ba5 | ||
|
|
faf12dcf8e | ||
|
|
70d3e6cd39 | ||
|
|
b1d790739f | ||
|
|
6d983167c8 | ||
|
|
c935e2457e | ||
|
|
d21e01805e | ||
|
|
b31b4e2a11 | ||
|
|
94b245b2cf | ||
|
|
f37cc57177 | ||
|
|
533e93e647 | ||
|
|
6f6b942ba4 | ||
|
|
5cbbaa61a9 | ||
|
|
e0fa460ab9 | ||
|
|
41a3f85ac2 | ||
|
|
8e2515a84b | ||
|
|
0e8613daae | ||
|
|
3dc08388d9 | ||
|
|
714b604546 | ||
|
|
89d22aa127 | ||
|
|
cb8f380dc0 | ||
|
|
b4f84b448d | ||
|
|
235c571347 | ||
|
|
3025e17e4c | ||
|
|
86947d83d7 | ||
|
|
0ab46ae861 | ||
|
|
2046695479 | ||
|
|
3ed9bdb85e | ||
|
|
a747a8108d | ||
|
|
17f5ca3cb0 | ||
|
|
4b12137077 | ||
|
|
f08eae2632 | ||
|
|
6f4ab78f47 | ||
|
|
855365d897 | ||
|
|
8403bf7a04 | ||
|
|
042ba11870 | ||
|
|
2fbec21378 | ||
|
|
178826683c | ||
|
|
37e5cbf315 | ||
|
|
a836a1c4b5 | ||
|
|
86e3921ca4 | ||
|
|
e765771500 | ||
|
|
a4000539f6 | ||
|
|
66ff58f42d | ||
|
|
6a46b9c084 | ||
|
|
4254446552 | ||
|
|
caf2f14e54 | ||
|
|
6372990f76 | ||
|
|
390db65e32 | ||
|
|
0a579b4507 | ||
|
|
1b79141b47 | ||
|
|
dfef66f4b5 | ||
|
|
458ae33eec | ||
|
|
4cc879104f | ||
|
|
1ac8a86f1c | ||
|
|
79e7c10ad9 | ||
|
|
03d9e62aaf | ||
|
|
c68823c478 | ||
|
|
3af2a6b6bc | ||
|
|
6644d8266e | ||
|
|
d2e3fee99a | ||
|
|
ed40bf51b0 | ||
|
|
f90630c566 | ||
|
|
c9ce2eedb1 | ||
|
|
d5249cc90e | ||
|
|
1bc3464102 | ||
|
|
3c3b0c02a8 | ||
|
|
bfd0343ee9 | ||
|
|
3ec301f2f5 | ||
|
|
5a23d4d326 | ||
|
|
03bf058dd7 | ||
|
|
15b0e33542 | ||
|
|
be6b0128b1 | ||
|
|
b67cb99f41 | ||
|
|
d95c1d66f0 | ||
|
|
4a2130d7d0 | ||
|
|
a16d781681 | ||
|
|
65d7d06d2c | ||
|
|
4c615f85e5 | ||
|
|
a14d8b5f90 | ||
|
|
eaebe7babd | ||
|
|
bab4a1581d | ||
|
|
bb6d34407d | ||
|
|
0d94d99d4b | ||
|
|
7dc6135416 | ||
|
|
bfea73d372 | ||
|
|
e641f06823 | ||
|
|
0c32730424 | ||
|
|
b639cfd6d4 | ||
|
|
c7dc0ae97d | ||
|
|
e5f7628087 | ||
|
|
158e9b1ed3 | ||
|
|
bb848de581 | ||
|
|
a3999d04dd | ||
|
|
190a87355e | ||
|
|
4a46e5e170 | ||
|
|
627fb1deb0 | ||
|
|
00ef6bb3a0 | ||
|
|
a6e8a777e6 | ||
|
|
35ef88e626 | ||
|
|
ba630173b8 | ||
|
|
073ba617ed | ||
|
|
13744a486a | ||
|
|
16e69a39d5 | ||
|
|
6cb543ec7d | ||
|
|
268acda75b | ||
|
|
0167347277 | ||
|
|
8d3c6f946e | ||
|
|
b2c4bcad34 | ||
|
|
6728010173 | ||
|
|
9895956531 | ||
|
|
0bb784c45b | ||
|
|
0dc6128b8e | ||
|
|
61eb915fb2 | ||
|
|
04f39d4e91 | ||
|
|
f14c945ff9 | ||
|
|
279aa5c8a7 | ||
|
|
bbe66a646f | ||
|
|
a5a4c9335a | ||
|
|
56912f6ed1 | ||
|
|
e51ea1ed61 | ||
|
|
ac2b99062e | ||
|
|
3d17e8f290 | ||
|
|
e46ae3bd6e | ||
|
|
38c43c1c95 | ||
|
|
7acdbcb4c9 | ||
|
|
ee8fb3414a | ||
|
|
ba2f989fa8 | ||
|
|
8c9259fa1d | ||
|
|
edb8194707 | ||
|
|
83399589c4 | ||
|
|
5b496e8403 | ||
|
|
359e3e1900 | ||
|
|
f718d1895f | ||
|
|
1b79a91295 | ||
|
|
4180104402 | ||
|
|
f831258893 | ||
|
|
f04e0b2269 | ||
|
|
ad1f1aaa5a | ||
|
|
1943227f21 | ||
|
|
7aa44d3197 | ||
|
|
452ad7b06b | ||
|
|
e86b660e05 | ||
|
|
498b653346 | ||
|
|
303e92dceb | ||
|
|
f222ebddea | ||
|
|
ca40b403a5 | ||
|
|
e3a1e1313c | ||
|
|
814f357021 | ||
|
|
1af9829c04 | ||
|
|
c277ac3746 | ||
|
|
9f19229a22 | ||
|
|
10be8820cb | ||
|
|
5d909a6023 | ||
|
|
ccb57c5ae1 | ||
|
|
fc277bb32a | ||
|
|
e7a17cf74f | ||
|
|
5e50ffbc30 | ||
|
|
375ad931f7 | ||
|
|
05eab5823e | ||
|
|
9b7512bbba | ||
|
|
3a976663f2 | ||
|
|
ebff5490b3 | ||
|
|
d5c8a4554c | ||
|
|
7cd3bddeeb | ||
|
|
8af6a9ae58 | ||
|
|
60d19584ee | ||
|
|
ee982bf807 | ||
|
|
0467e59b28 | ||
|
|
aed19d84b5 | ||
|
|
aee2ca2e47 | ||
|
|
b6bfbf3090 | ||
|
|
61089c9a09 | ||
|
|
9d943ed773 | ||
|
|
6e5ba6e892 | ||
|
|
dced08f0f6 | ||
|
|
1bca8e4bfa | ||
|
|
35b99cf6c0 | ||
|
|
37e866ed6e | ||
|
|
f83ba31af5 | ||
|
|
f1b7232d37 | ||
|
|
f910756d35 | ||
|
|
32b0159d9d | ||
|
|
36bef45b5e | ||
|
|
0b177f971f | ||
|
|
2c54c988ce | ||
|
|
4883530087 | ||
|
|
2daa7cc327 | ||
|
|
fdeb6f9cd8 | ||
|
|
f8cdd76fa9 | ||
|
|
67fbba4708 | ||
|
|
38cb3d2df6 | ||
|
|
fa589fd78f | ||
|
|
d53a4e8c79 | ||
|
|
ba3803ab8c | ||
|
|
433e53926c | ||
|
|
22d4f18e97 | ||
|
|
4a40d89783 | ||
|
|
fad7133959 | ||
|
|
6804c6ec00 | ||
|
|
de89e56a47 | ||
|
|
97e0059475 | ||
|
|
29c97964d1 | ||
|
|
2071b92d3e | ||
|
|
9674bce96e | ||
|
|
72da2d43d8 | ||
|
|
f22674a0b2 | ||
|
|
43ece4c10f | ||
|
|
304efd83b6 | ||
|
|
4697e69e23 | ||
|
|
af3bbd9320 | ||
|
|
742b79e473 | ||
|
|
1a619e1dbd | ||
|
|
2c9bfb3c80 | ||
|
|
3102148485 | ||
|
|
f8a7c40c11 | ||
|
|
7603772075 | ||
|
|
33c8528c1a | ||
|
|
d7978d39c9 | ||
|
|
722b1c60d2 | ||
|
|
b0136b0524 | ||
|
|
7333941a38 | ||
|
|
27934c1188 | ||
|
|
247b24e1a3 | ||
|
|
fb6c56e1aa | ||
|
|
db4b2487f5 | ||
|
|
f1fbca6fc9 | ||
|
|
3308387e20 | ||
|
|
ba00c917cf | ||
|
|
b476ca0080 | ||
|
|
e9c33a405b | ||
|
|
56247431de | ||
|
|
cae46c5db6 | ||
|
|
9cbfbb9231 | ||
|
|
9f49424e67 | ||
|
|
f290419694 | ||
|
|
82564712c3 | ||
|
|
ed1532d1f5 | ||
|
|
2b4a3f2281 | ||
|
|
e1f32a13ab | ||
|
|
5a2305193b | ||
|
|
f8b9d2e271 | ||
|
|
a1ced7573b | ||
|
|
0ec50a1ee4 | ||
|
|
1d74d0b223 | ||
|
|
7333f1357e | ||
|
|
82ccd5c755 | ||
|
|
577d7af7f8 | ||
|
|
ba7c0f6517 | ||
|
|
8c55be23cc | ||
|
|
63ad6fe1e9 | ||
|
|
fb7136e1b0 | ||
|
|
e814eff7e2 | ||
|
|
bb093764ba | ||
|
|
1f5a601370 | ||
|
|
389d431005 | ||
|
|
d9d8d7891e | ||
|
|
18631f1a1a | ||
|
|
67d0f68eb7 | ||
|
|
82de99973c | ||
|
|
973fbd9fc6 | ||
|
|
45ab04af04 | ||
|
|
4d35795899 | ||
|
|
6335e51f30 | ||
|
|
f5ca535b70 | ||
|
|
6b5cf545df | ||
|
|
62a2b34b38 | ||
|
|
b61ca66d29 | ||
|
|
0ba3e6e155 | ||
|
|
d2a09427ed | ||
|
|
752a1d44d7 | ||
|
|
8fd4a0bd60 | ||
|
|
8d9605658f | ||
|
|
c1fb58dab7 | ||
|
|
7c5b49876a | ||
|
|
5368f9a16a | ||
|
|
15f06d1168 | ||
|
|
7f0a5984f3 | ||
|
|
c0f5b00979 | ||
|
|
61883506b0 | ||
|
|
e83538e510 | ||
|
|
e7c024032a | ||
|
|
f114657607 | ||
|
|
377cbbe8c8 | ||
|
|
1834703b1e | ||
|
|
a75b6b667b | ||
|
|
ec3ecb832a | ||
|
|
482b9a291d | ||
|
|
0fe8bfe0d3 | ||
|
|
914acd201e | ||
|
|
3b88eba110 | ||
|
|
258f800f97 | ||
|
|
71bfe4f03c | ||
|
|
d4e5bae03b | ||
|
|
78503c8990 | ||
|
|
cbebb18418 | ||
|
|
9f5081a3a4 | ||
|
|
a76413fd33 | ||
|
|
c83a91eec4 | ||
|
|
7c68830b45 | ||
|
|
fbecabf3fa | ||
|
|
0476b725f4 | ||
|
|
1733371a90 | ||
|
|
d0766a3865 | ||
|
|
d2715b5978 | ||
|
|
dd053ac706 | ||
|
|
04336fedae | ||
|
|
0bc9ae66ed | ||
|
|
622766fea3 | ||
|
|
bd76e760d4 | ||
|
|
540d5030a4 | ||
|
|
d9466717a7 | ||
|
|
edbc22e02f | ||
|
|
6c6f7021d1 | ||
|
|
8862239a11 | ||
|
|
ca2088f553 | ||
|
|
67edf2ce4d | ||
|
|
9857a0b981 | ||
|
|
d1429efaa8 | ||
|
|
223b6ae096 | ||
|
|
e2e40d1fdc | ||
|
|
73e117e693 | ||
|
|
a587503160 | ||
|
|
ca9aabaa63 | ||
|
|
3e4f5fbfdf | ||
|
|
ab34fe725c | ||
|
|
70f6fcc722 | ||
|
|
10287bd9a5 | ||
|
|
91bd69f9d1 | ||
|
|
d2de4eac41 | ||
|
|
cf206240cd | ||
|
|
09043deecc | ||
|
|
d686ed208f | ||
|
|
a607a23abb | ||
|
|
0603ec56ce | ||
|
|
6de052df6b | ||
|
|
588440dcc1 | ||
|
|
794614f6e0 | ||
|
|
f85b6f9644 | ||
|
|
74629f47d9 | ||
|
|
d60fc67da7 | ||
|
|
16a2a48a88 | ||
|
|
840bb4e31a | ||
|
|
f1212118d8 | ||
|
|
8cb38d3c3f | ||
|
|
aec54a4565 | ||
|
|
88b4344a90 | ||
|
|
476400a02e | ||
|
|
bb9a911e59 | ||
|
|
fb77e54d54 | ||
|
|
a4d699b3d7 | ||
|
|
ec31ad339e | ||
|
|
dfa91cd085 | ||
|
|
424f1d061a | ||
|
|
bc52c0cfbe | ||
|
|
2d3ca43e01 | ||
|
|
0bc4a11fc5 | ||
|
|
dc63c2e9d4 | ||
|
|
46e56ac315 | ||
|
|
1903674147 | ||
|
|
79023f35cb | ||
|
|
615188cba6 | ||
|
|
437973a2ba | ||
|
|
cd68a12b71 | ||
|
|
d34525776d | ||
|
|
cb4b9c82c8 | ||
|
|
f303b466c9 | ||
|
|
93ff9402b1 | ||
|
|
27c5626ef6 | ||
|
|
636192af87 | ||
|
|
c84694b3bb | ||
|
|
e825f47d0a | ||
|
|
fcc88b389e | ||
|
|
22bd61580b | ||
|
|
eab0bf9494 | ||
|
|
41e6682f66 | ||
|
|
aabc8e12b0 | ||
|
|
a2487aeea8 | ||
|
|
1e04a6cc0a | ||
|
|
8ed874d4ea | ||
|
|
2117fda50f | ||
|
|
da1a5f6506 | ||
|
|
803f87de38 | ||
|
|
67948002fd | ||
|
|
e76617c9a9 | ||
|
|
cc4fd82fef | ||
|
|
05d379da08 | ||
|
|
8ab7f2c8b3 | ||
|
|
a1d0129f36 | ||
|
|
0c54816b3f | ||
|
|
e1c35d299d | ||
|
|
89c6b36090 | ||
|
|
cd35c77df1 | ||
|
|
c6648655cf | ||
|
|
d139df6a2c | ||
|
|
235567400e | ||
|
|
e5e03c76a3 | ||
|
|
58960eb6d4 | ||
|
|
675f90adc6 | ||
|
|
dbdfb2226b | ||
|
|
d4eef5ecd0 | ||
|
|
ecf904d99f | ||
|
|
5d43f4b1e6 | ||
|
|
f1874c7637 | ||
|
|
78be705f70 | ||
|
|
00df91f30d | ||
|
|
64070616c0 | ||
|
|
99e15b5a9b | ||
|
|
f33af1dcf3 | ||
|
|
2a54ebb091 | ||
|
|
b5ce2a9d36 | ||
|
|
0379edc684 | ||
|
|
d781568f93 | ||
|
|
cc95998339 | ||
|
|
1b364ae3de | ||
|
|
f1a4d8d38b | ||
|
|
1b333f774a | ||
|
|
ccbaa1fe6d | ||
|
|
78bb3155e0 | ||
|
|
89bad8cb11 | ||
|
|
f8d8776667 | ||
|
|
36ae1b521b | ||
|
|
48187393a8 | ||
|
|
a38961ad84 | ||
|
|
1d6957d263 | ||
|
|
53c9279049 | ||
|
|
c2458fff8e | ||
|
|
77fbf8a745 | ||
|
|
d90cd01fab | ||
|
|
d5772901d9 | ||
|
|
8984d9e166 | ||
|
|
b633702747 | ||
|
|
ea2884ed60 | ||
|
|
c95919ba7f | ||
|
|
c8dc730fb7 | ||
|
|
45462c49da | ||
|
|
a191948675 | ||
|
|
8154a398a8 | ||
|
|
ef353e1c8f | ||
|
|
aaacc41c82 | ||
|
|
863758b49f | ||
|
|
5fe66a1e4f | ||
|
|
7e5c0a5716 | ||
|
|
41d182e987 | ||
|
|
bd553fa630 | ||
|
|
d4f48a3ebd | ||
|
|
b8fe4e2b35 | ||
|
|
7f14e99fbf |
@@ -3,6 +3,6 @@
|
||||
"enabled": false
|
||||
},
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1714413381505
|
||||
"lastUpdateCheck": 1720192549979
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
name: "✍️ Suggest Changes"
|
||||
name: "✍️ Missing or Deprecated Roadmap Topics"
|
||||
description: Help us improve the roadmaps by suggesting changes
|
||||
labels: [suggestion]
|
||||
labels: [topic-change]
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
|
||||
50
.github/workflows/close-feedback-pr.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Close PRs with Feedback
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
jobs:
|
||||
close-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close PR if it has label "feedback left" and no changes in 7 days
|
||||
uses: actions/github-script@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { data: pullRequests } = await github.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
base: 'master',
|
||||
});
|
||||
|
||||
for (const pullRequest of pullRequests) {
|
||||
const { data: labels } = await github.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
});
|
||||
|
||||
const feedbackLabel = labels.find((label) => label.name === 'feedback left');
|
||||
if (feedbackLabel) {
|
||||
const lastUpdated = new Date(pullRequest.updated_at);
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
if (lastUpdated < sevenDaysAgo) {
|
||||
await github.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: 'Closing this PR because there has been no activity for the past 7 days. Feel free to reopen if you have any feedback.',
|
||||
});
|
||||
await github.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullRequest.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
4
.github/workflows/cloudfront-cache.yml
vendored
@@ -1,10 +1,6 @@
|
||||
name: Clears Cloudfront Cache
|
||||
on:
|
||||
# Allow manual Run
|
||||
workflow_dispatch:
|
||||
# Run at midnight utc
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
jobs:
|
||||
aws_costs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
18
.github/workflows/deployment.yml
vendored
@@ -1,9 +1,6 @@
|
||||
name: Deploy to EC2
|
||||
on:
|
||||
workflow_dispatch: # allow manual run
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -59,4 +56,17 @@ jobs:
|
||||
key: ${{ secrets.EC2_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /var/www/roadmap.sh
|
||||
sudo pm2 restart web-roadmap
|
||||
sudo pm2 restart web-roadmap
|
||||
|
||||
# --------------------
|
||||
# Clear cloudfront cache
|
||||
# --------------------
|
||||
- name: Clear Cloudfront Caching
|
||||
run: |
|
||||
curl -L \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/roadmapsh/infra-ansible/actions/workflows/playbook.yml/dispatches \
|
||||
-d '{ "ref":"master", "inputs": { "playbook": "roadmap_web.yml", "tags": "cloudfront", "is_verbose": false } }'
|
||||
38
.github/workflows/label-issue.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Label Issue
|
||||
on:
|
||||
issues:
|
||||
types: [ opened, edited ]
|
||||
jobs:
|
||||
label-topic-change-issue:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add roadmap slug to issue as label
|
||||
uses: actions/github-script@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const roadmapUrl = issue.body.match(/https?:\/\/roadmap.sh\/[^ ]+/);
|
||||
|
||||
// if the issue is labeled as a topic-change, add the roadmap slug as a label
|
||||
if (issue.labels.some(label => label.name === 'topic-change')) {
|
||||
if (roadmapUrl) {
|
||||
const roadmapSlug = new URL(roadmapUrl[0]).pathname.replace(/\//, '');
|
||||
github.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [roadmapSlug]
|
||||
});
|
||||
}
|
||||
|
||||
// Close the issue if it has no roadmap URL
|
||||
if (!roadmapUrl) {
|
||||
github.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
@@ -31,3 +31,5 @@ tests-examples
|
||||
|
||||
/editor/*
|
||||
!/editor/readonly-editor.tsx
|
||||
!/editor/renderer/renderer.ts
|
||||
!/editor/renderer/index.tsx
|
||||
|
||||
@@ -11,6 +11,9 @@ import react from '@astrojs/react';
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://roadmap.sh/',
|
||||
experimental: {
|
||||
rewriting: true,
|
||||
},
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
theme: 'dracula',
|
||||
|
||||
@@ -2,40 +2,99 @@
|
||||
|
||||
First of all thank you for considering to contribute. Please look at the details below:
|
||||
|
||||
- [Contribution](#contribution)
|
||||
- [New Roadmaps](#new-roadmaps)
|
||||
- [Existing Roadmaps](#existing-roadmaps)
|
||||
- [Adding Content](#adding-content)
|
||||
- [Guidelines](#guidelines)
|
||||
- [New Roadmaps](#new-roadmaps)
|
||||
- [Existing Roadmaps](#existing-roadmaps)
|
||||
- [Adding Content](#adding-content)
|
||||
- [Guidelines](#guidelines)
|
||||
|
||||
## New Roadmaps
|
||||
|
||||
For new roadmaps, submit a roadmap by providing [a textual roadmap similar to this roadmap](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5) in an issue.
|
||||
For new roadmaps, you can either:
|
||||
- Submit a roadmap by providing [a textual roadmap similar to this roadmap](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5) in an [issue](https://github.com/kamranahmedse/developer-roadmap/issues).
|
||||
- Create an interactive roadmap yourself using [our roadmap editor](https://draw.roadmap.sh/) & submit the link to that roadmap in an [issue](https://github.com/kamranahmedse/developer-roadmap/issues).
|
||||
|
||||
## Existing Roadmaps
|
||||
|
||||
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/src/data/roadmaps)
|
||||
- **Adding or Removing Nodes** — Please open an issue with your suggestion.
|
||||
- **Fixing Typos** — Make your changes in the [roadmap JSON file](https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/roadmaps) and submit a [PR](https://github.com/kamranahmedse/developer-roadmap/pulls).
|
||||
- **Adding or Removing Nodes** — Please open an [issue](https://github.com/kamranahmedse/developer-roadmap/issues) 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.
|
||||
**Note:** Please note that our goal is <strong>not to have the biggest list of items</strong>. Our goal is to list items or skills most relevant today.
|
||||
|
||||
## Adding Content
|
||||
|
||||
Find [the content directory inside the relevant roadmap](https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/roadmaps). Please keep the following guidelines in mind when submitting content:
|
||||
|
||||
- Content must be in English.
|
||||
- Put a brief description about the topic on top of the file and the a list of links below with each link having title of the URL.
|
||||
- Maximum of 8 links per topic.
|
||||
- Follow the below style guide for content.
|
||||
|
||||
### How To Structure Content
|
||||
|
||||
Please adhere to the following style when adding content to a topic:
|
||||
|
||||
```
|
||||
# Topic Title
|
||||
|
||||
(Content)
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@type@Description of link](Link)
|
||||
```
|
||||
|
||||
`@type@` must be one of the following and describes the type of content you are adding:
|
||||
|
||||
- `@official@`
|
||||
- `@opensource@`
|
||||
- `@article@`
|
||||
- `@course@`
|
||||
- `@podcast@`
|
||||
- `@video@`
|
||||
|
||||
It's important to add a valid type, this will help us categorize the content and display it properly on the roadmap.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- <p><strong>Please don't use the project for self-promotion!</strong><br />
|
||||
|
||||
We believe this project is a valuable asset to the developer community and it includes numerous helpful resources. We kindly ask you to avoid submitting pull requests for the sole purpose of self-promotion. We appreciate contributions that genuinely add value, such as guides from maintainers of well-known frameworks, and will consider accepting these even if they're self authored. Thank you for your understanding and cooperation!
|
||||
|
||||
- <p><strong>Adding everything available out there is not the goal!</strong><br />
|
||||
The roadmaps represent the skillset most valuable today, i.e., if you were to enter any of the listed fields today, what would you learn?! There might be things that are of-course being used today but prioritize the things that are most in demand today, e.g., agreed that lots of people are using angular.js today but you wouldn't want to learn that instead of React, Angular, or Vue. Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included.</p>
|
||||
|
||||
The roadmaps represent the skillset most valuable today, i.e., if you were to enter any of the listed fields today, what would you learn? There might be things that are of-course being used today but prioritize the things that are most in demand today, e.g., agreed that lots of people are using angular.js today but you wouldn't want to learn that instead of React, Angular, or Vue. Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included.</p>
|
||||
|
||||
- <p><strong>Do not add things you have not evaluated personally!</strong><br />
|
||||
|
||||
Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included. Have you read this book? Can you give a short article?</p>
|
||||
|
||||
- <p><strong>Create a Single PR for Content Additions</strong></p>
|
||||
|
||||
If you are planning to contribute by adding content to the roadmaps, I recommend you to clone the repository, add content to the [content directory of the roadmap](./src/data/roadmaps/) and create a single PR to make it easier for me to review and merge the PR.
|
||||
- Write meaningful commit messages
|
||||
- Look at the existing issues/pull requests before opening new ones
|
||||
|
||||
- <p><strong>Write meaningful commit messages</strong><br >
|
||||
|
||||
Meaningful commit messages help speed up the review process as well as help other contributors in gaining a good overview of the repositories commit history without having to dive into every commit.
|
||||
|
||||
(See the following guide on how to write good [commit messages](https://www.freecodecamp.org/news/how-to-write-better-git-commit-messages/)).
|
||||
|
||||
</p>
|
||||
- <p><strong>Look at the existing issues/pull requests before opening new ones</strong></p>
|
||||
|
||||
### Good vs Not So Good Contributions
|
||||
|
||||
<strong>Good</strong>
|
||||
|
||||
- New Roadmaps.
|
||||
- Engaging, fresh content links.
|
||||
- Typos and grammatical fixes.
|
||||
- Content copy in topics that do not have any (or minimal copy exists).
|
||||
|
||||
<strong>Not So Good</strong>
|
||||
|
||||
- Adding whitespace that doesn't add to the readability of the content.
|
||||
- Rewriting content in a way that doesn't add any value.
|
||||
- Non-English content.
|
||||
- PR's that don't follow our style guide, have no description and a default title.
|
||||
- Links to your own blog articles.
|
||||
|
||||
14
editor/renderer/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export function Renderer(props: any) {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 top-0 z-[9999] border bg-white p-5 text-black">
|
||||
<h2 className="mb-2 text-xl font-semibold">Private Component</h2>
|
||||
<p className="mb-4">
|
||||
Renderer is a private component. If you are a collaborator and have
|
||||
access to it. Run the following command:
|
||||
</p>
|
||||
<code className="mt-5 rounded-md bg-gray-800 p-2 text-white">
|
||||
npm run generate-renderer
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
editor/renderer/renderer.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function renderFlowJSON(data: any, options?: any) {
|
||||
console.warn("renderFlowJSON is not implemented");
|
||||
console.warn("run the following command to generate the renderer:");
|
||||
console.warn("> npm run generate-renderer");
|
||||
}
|
||||
49
package.json
@@ -9,30 +9,36 @@
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"format": "prettier --write .",
|
||||
"gh-labels": "./scripts/create-roadmap-labels.sh",
|
||||
"astro": "astro",
|
||||
"deploy": "NODE_DEBUG=gh-pages gh-pages -d dist -t",
|
||||
"upgrade": "ncu -u",
|
||||
"roadmap-links": "node scripts/roadmap-links.cjs",
|
||||
"roadmap-dirs": "node scripts/roadmap-dirs.cjs",
|
||||
"roadmap-assets": "tsx scripts/editor-roadmap-assets.ts",
|
||||
"editor-roadmap-dirs": "tsx scripts/editor-roadmap-dirs.ts",
|
||||
"editor-roadmap-content": "tsx scripts/editor-roadmap-content.ts",
|
||||
"roadmap-content": "node scripts/roadmap-content.cjs",
|
||||
"generate-renderer": "sh scripts/generate-renderer.sh",
|
||||
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
|
||||
"best-practice-content": "node scripts/best-practice-content.cjs",
|
||||
"generate:og": "node ./scripts/generate-og-images.mjs",
|
||||
"warm:urls": "sh ./scripts/warm-urls.sh https://roadmap.sh/sitemap-0.xml",
|
||||
"compress:images": "tsx ./scripts/compress-images.ts",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^8.2.5",
|
||||
"@astrojs/react": "^3.3.1",
|
||||
"@astrojs/sitemap": "^3.1.4",
|
||||
"@astrojs/node": "^8.3.2",
|
||||
"@astrojs/react": "^3.6.0",
|
||||
"@astrojs/sitemap": "^3.1.6",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@fingerprintjs/fingerprintjs": "^4.3.0",
|
||||
"@fingerprintjs/fingerprintjs": "^4.4.1",
|
||||
"@nanostores/react": "^0.7.2",
|
||||
"@napi-rs/image": "^1.9.2",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"astro": "^4.7.0",
|
||||
"astro": "^4.11.3",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"dom-to-image": "^2.6.0",
|
||||
@@ -40,47 +46,48 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"htm": "^3.1.1",
|
||||
"image-size": "^1.1.1",
|
||||
"jose": "^5.2.4",
|
||||
"jose": "^5.6.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.376.0",
|
||||
"lucide-react": "^0.399.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"nanostores": "^0.10.3",
|
||||
"node-html-parser": "^6.1.13",
|
||||
"npm-check-updates": "^16.14.20",
|
||||
"playwright": "^1.45.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.3.1",
|
||||
"react-calendar-heatmap": "^1.9.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-tooltip": "^5.26.4",
|
||||
"reactflow": "^11.11.2",
|
||||
"react-tooltip": "^5.27.0",
|
||||
"reactflow": "^11.11.4",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"satori": "^0.10.13",
|
||||
"satori-html": "^0.3.2",
|
||||
"sharp": "^0.33.3",
|
||||
"sharp": "^0.33.4",
|
||||
"slugify": "^1.6.6",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"unified": "^11.0.4",
|
||||
"zustand": "^4.5.2"
|
||||
"tailwindcss": "^3.4.4",
|
||||
"unified": "^11.0.5",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.43.1",
|
||||
"@playwright/test": "^1.45.0",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/prismjs": "^1.26.4",
|
||||
"@types/react-calendar-heatmap": "^1.6.7",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^6.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"openai": "^4.38.5",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-astro": "^0.13.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"tsx": "^4.7.3"
|
||||
"openai": "^4.52.2",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-astro": "^0.14.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tsx": "^4.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
10055
pnpm-lock.yaml
generated
BIN
public/authors/ekene-eze.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/authors/william-imoh.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/pdfs/roadmaps/api-design.pdf
Normal file
BIN
public/pdfs/roadmaps/devrel.pdf
Normal file
BIN
public/pdfs/roadmaps/ios.pdf
Normal file
BIN
public/pdfs/roadmaps/product-manager.pdf
Normal file
BIN
public/pdfs/roadmaps/terraform.pdf
Normal file
BIN
public/roadmaps/api-design.png
Normal file
|
After Width: | Height: | Size: 586 KiB |
BIN
public/roadmaps/devrel.png
Normal file
|
After Width: | Height: | Size: 723 KiB |
BIN
public/roadmaps/ios.png
Normal file
|
After Width: | Height: | Size: 672 KiB |
BIN
public/roadmaps/product-manager.png
Normal file
|
After Width: | Height: | Size: 675 KiB |
BIN
public/roadmaps/terraform.png
Normal file
|
After Width: | Height: | Size: 462 KiB |
@@ -36,13 +36,16 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [Backend Roadmap](https://roadmap.sh/backend) / [Backend Beginner Roadmap](https://roadmap.sh/backend?r=backend-beginner)
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
|
||||
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
|
||||
- [API Design Roadmap](https://roadmap.sh/api-design)
|
||||
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
|
||||
- [Data Structures and Algorithms Roadmap](https://roadmap.sh/datastructures-and-algorithms)
|
||||
- [AI and Data Scientist Roadmap](https://roadmap.sh/ai-data-scientist)
|
||||
- [AWS Roadmap](https://roadmap.sh/aws)
|
||||
- [Linux Roadmap](https://roadmap.sh/linux)
|
||||
- [Terraform Roadmap](https://roadmap.sh/terraform)
|
||||
- [Data Analyst Roadmap](https://roadmap.sh/data-analyst)
|
||||
- [MLOps Roadmap](https://roadmap.sh/mlops)
|
||||
- [Product Manager Roadmap](https://roadmap.sh/product-manager)
|
||||
- [QA Roadmap](https://roadmap.sh/qa)
|
||||
- [Python Roadmap](https://roadmap.sh/python)
|
||||
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
|
||||
@@ -58,6 +61,7 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [Node.js Roadmap](https://roadmap.sh/nodejs)
|
||||
- [GraphQL Roadmap](https://roadmap.sh/graphql)
|
||||
- [Android Roadmap](https://roadmap.sh/android)
|
||||
- [iOS Roadmap](https://roadmap.sh/ios)
|
||||
- [Flutter Roadmap](https://roadmap.sh/flutter)
|
||||
- [Go Roadmap](https://roadmap.sh/golang)
|
||||
- [Rust Roadmap](https://roadmap.sh/rust)
|
||||
@@ -76,6 +80,7 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [Docker Roadmap](https://roadmap.sh/docker)
|
||||
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
|
||||
- [Technical Writer Roadmap](https://roadmap.sh/technical-writer)
|
||||
- [DevRel Engineer Roadmap](https://roadmap.sh/devrel)
|
||||
|
||||
There are also interactive best practices:
|
||||
|
||||
@@ -90,6 +95,8 @@ There are also interactive best practices:
|
||||
- [JavaScript Questions](https://roadmap.sh/questions/javascript)
|
||||
- [Node.js Questions](https://roadmap.sh/questions/nodejs)
|
||||
- [React Questions](https://roadmap.sh/questions/react)
|
||||
- [Backend Questions](https://roadmap.sh/questions/backend)
|
||||
- [Frontend Questions](https://roadmap.sh/questions/frontend)
|
||||
|
||||

|
||||
|
||||
@@ -109,6 +116,7 @@ Clone the repository, install the dependencies and start the application
|
||||
|
||||
```bash
|
||||
git clone git@github.com:kamranahmedse/developer-roadmap.git
|
||||
cd developer-roadmap
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
189
scripts/assign-label-types.cjs
Normal file
@@ -0,0 +1,189 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const allRoadmapDirs = fs.readdirSync(
|
||||
path.join(__dirname, '../src/data/roadmaps'),
|
||||
);
|
||||
|
||||
allRoadmapDirs.forEach((roadmapId) => {
|
||||
const roadmapDir = path.join(
|
||||
__dirname,
|
||||
`../src/data/roadmaps/${roadmapId}/content`,
|
||||
);
|
||||
|
||||
function getHostNameWithoutTld(hostname) {
|
||||
const parts = hostname.split('.');
|
||||
return parts.slice(0, parts.length - 1).join('.');
|
||||
}
|
||||
|
||||
function isOfficialWebsite(hostname, fileName, roadmapId) {
|
||||
fileName = fileName.replace('/index.md', '').replace('.md', '');
|
||||
|
||||
const parts = fileName.split('/');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
|
||||
const normalizedFilename = lastPart.replace(/\d+/g, '').replace(/-/g, '');
|
||||
const normalizedHostname = getHostNameWithoutTld(hostname);
|
||||
|
||||
if (normalizedFilename === normalizedHostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedFilename.includes(normalizedHostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!roadmapId.includes(normalizedHostname);
|
||||
}
|
||||
|
||||
// websites are educational websites that are of following types:
|
||||
// - @official@
|
||||
// - @article@
|
||||
// - @course@
|
||||
// - @opensource@
|
||||
// - @podcast@
|
||||
// - @video@
|
||||
// - @website@
|
||||
// content is only educational websites
|
||||
function getTypeFromHostname(hostname, fileName, roadmapId) {
|
||||
hostname = hostname.replace('www.', '');
|
||||
|
||||
const videoHostnames = ['youtube.com', 'vimeo.com', 'youtu.be'];
|
||||
const courseHostnames = ['coursera.org', 'udemy.com', 'edx.org'];
|
||||
const podcastHostnames = ['spotify.com', 'apple.com'];
|
||||
const opensourceHostnames = ['github.com', 'gitlab.com'];
|
||||
const articleHostnames = [
|
||||
'neilpatel.com',
|
||||
'learningseo.io',
|
||||
'htmlreference.io',
|
||||
'docs.gitlab.com',
|
||||
'docs.github.com',
|
||||
'skills.github.com',
|
||||
'cloudflare.com',
|
||||
'w3schools.com',
|
||||
'medium.com',
|
||||
'dev.to',
|
||||
'web.dev',
|
||||
'css-tricks.com',
|
||||
'developer.mozilla.org',
|
||||
'smashingmagazine.com',
|
||||
'freecodecamp.org',
|
||||
'cs.fyi',
|
||||
'thenewstack.io',
|
||||
'html5rocks.com',
|
||||
'html.com',
|
||||
'javascript.info',
|
||||
'css-tricks.com',
|
||||
'developer.apple.com',
|
||||
];
|
||||
|
||||
if (articleHostnames.includes(hostname)) {
|
||||
return 'article';
|
||||
}
|
||||
|
||||
if (videoHostnames.includes(hostname)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (courseHostnames.includes(hostname)) {
|
||||
return 'course';
|
||||
}
|
||||
|
||||
if (podcastHostnames.includes(hostname)) {
|
||||
return 'podcast';
|
||||
}
|
||||
|
||||
if (opensourceHostnames.includes(hostname)) {
|
||||
return 'opensource';
|
||||
}
|
||||
|
||||
if (hostname === 'roadmap.sh') {
|
||||
return 'roadmap.sh';
|
||||
}
|
||||
|
||||
if (isOfficialWebsite(hostname, fileName, roadmapId)) {
|
||||
return 'official';
|
||||
}
|
||||
|
||||
return 'article';
|
||||
}
|
||||
|
||||
function readNestedMarkdownFiles(dir, files = []) {
|
||||
const dirEnts = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const dirent of dirEnts) {
|
||||
const fullPath = path.join(dir, dirent.name);
|
||||
if (dirent.isDirectory()) {
|
||||
readNestedMarkdownFiles(fullPath, files);
|
||||
} else {
|
||||
if (path.extname(fullPath) === '.md') {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const files = readNestedMarkdownFiles(roadmapDir);
|
||||
|
||||
// for each of the files, assign the type of link to the beginning of each markdown link
|
||||
// i.e. - [@article@abc](xyz) where @article@ is the type of link. Possible types:
|
||||
// - @official@
|
||||
// - @opensource@
|
||||
// - @article@
|
||||
// - @course@
|
||||
// - @opensource@
|
||||
// - @podcast@
|
||||
// - @video@
|
||||
files.forEach((file) => {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
const newContent = lines
|
||||
.map((line) => {
|
||||
if (line.startsWith('- [') && !line.startsWith('- [@')) {
|
||||
const type = line.match(/@(\w+)@/);
|
||||
if (type) {
|
||||
return line;
|
||||
}
|
||||
|
||||
let urlMatches = line.match(/\((https?:\/\/[^)]+)\)/);
|
||||
let fullUrl = urlMatches?.[1];
|
||||
|
||||
if (!fullUrl) {
|
||||
// is it slashed URL i.e. - [abc](/xyz)
|
||||
fullUrl = line.match(/\((\/[^)]+)\)/)?.[1];
|
||||
if (fullUrl) {
|
||||
fullUrl = `https://roadmap.sh${fullUrl}`;
|
||||
}
|
||||
|
||||
if (!fullUrl) {
|
||||
console.error('Invalid URL found in:', file);
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(fullUrl);
|
||||
const hostname = url.hostname;
|
||||
|
||||
let urlType = getTypeFromHostname(hostname, file, roadmapId);
|
||||
const linkText = line.match(/\[([^\]]+)\]/)[1];
|
||||
|
||||
if (
|
||||
linkText.toLowerCase().startsWith('visit dedicated') &&
|
||||
linkText.toLowerCase().endsWith('roadmap')
|
||||
) {
|
||||
urlType = 'roadmap';
|
||||
}
|
||||
|
||||
return line.replace('- [', `- [@${urlType}@`).replace('](', '](');
|
||||
}
|
||||
|
||||
return line;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
fs.writeFileSync(file, newContent);
|
||||
});
|
||||
});
|
||||
31
scripts/close-issues.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Fetch issues JSON data and parse it properly
|
||||
issues=$(gh issue list --repo kamranahmedse/developer-roadmap --search "sort:created-asc" --state open --limit 500 --json number,title,createdAt,updatedAt,state,url,comments,reactionGroups,body | jq -c '.[]')
|
||||
|
||||
# Loop through the issues and delete the ones created in 2022 and not updated in the past year
|
||||
while IFS= read -r issue; do
|
||||
created_at=$(echo "$issue" | jq -r '.createdAt')
|
||||
updated_at=$(echo "$issue" | jq -r '.updatedAt')
|
||||
issue_number=$(echo "$issue" | jq -r '.number')
|
||||
issue_title=$(echo "$issue" | jq -r '.title')
|
||||
reaction_groups=$(echo "$issue" | jq -r '.reactionGroups')
|
||||
has_reactions=$(echo "$issue" | jq -r '.reactionGroups | length')
|
||||
comment_count=$(echo "$issue" | jq -r '.comments | length')
|
||||
body_characters=$(echo "$issue" | jq -r '.body | length')
|
||||
|
||||
# if has empty body
|
||||
if [[ "$created_at" == 2024-01* ]]; then
|
||||
|
||||
comment="Hey there!
|
||||
|
||||
Looks like this issue has been hanging around for a bit without much action. Our roadmaps have evolved quite a bit since then, and a bunch of older issues aren't really applicable anymore. So, we're tidying things up by closing out the older ones to keep our issue tracker nice and organized for future feedback.
|
||||
|
||||
If you still think this problem needs addressing, don't hesitate to reopen the issue. We're here to help!
|
||||
|
||||
Thanks a bunch!"
|
||||
|
||||
gh issue comment "$issue_number" --body "$comment"
|
||||
gh issue close "$issue_number"
|
||||
fi
|
||||
done <<< "$issues"
|
||||
11
scripts/create-roadmap-labels.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# get all the folder names inside src/data/roadmaps
|
||||
roadmap_ids=$(ls src/data/roadmaps)
|
||||
|
||||
# create a label for each roadmap name on github issues using gh cli
|
||||
for roadmap_id in $roadmap_ids
|
||||
do
|
||||
random_color=$(openssl rand -hex 3)
|
||||
gh label create "$roadmap_id" --color $random_color --description "Roadmap: $roadmap_id"
|
||||
done
|
||||
76
scripts/editor-roadmap-assets.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import playwright from 'playwright';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import matter from 'gray-matter';
|
||||
import type { RoadmapFrontmatter } from '../src/lib/roadmap';
|
||||
|
||||
// ERROR: `__dirname` is not defined in ES module scope
|
||||
// https://iamwebwiz.medium.com/how-to-fix-dirname-is-not-defined-in-es-module-scope-34d94a86694d
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Usage: tsx ./scripts/editor-roadmap-dirs.ts <roadmapId>
|
||||
|
||||
// Directory containing the roadmaps
|
||||
const ROADMAP_CONTENT_DIR = path.join(__dirname, '../src/data/roadmaps');
|
||||
const roadmapId = process.argv[2];
|
||||
|
||||
const allowedRoadmapIds = await fs.readdir(ROADMAP_CONTENT_DIR);
|
||||
if (!roadmapId) {
|
||||
console.error('Roadmap Id is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!allowedRoadmapIds.includes(roadmapId)) {
|
||||
console.error(`Invalid roadmap key ${roadmapId}`);
|
||||
console.error(`Allowed keys are ${allowedRoadmapIds.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roadmapFrontmatterDir = path.join(
|
||||
ROADMAP_CONTENT_DIR,
|
||||
roadmapId,
|
||||
`${roadmapId}.md`,
|
||||
);
|
||||
const roadmapFrontmatterRaw = await fs.readFile(roadmapFrontmatterDir, 'utf-8');
|
||||
const { data } = matter(roadmapFrontmatterRaw);
|
||||
|
||||
const roadmapFrontmatter = data as RoadmapFrontmatter;
|
||||
if (!roadmapFrontmatter) {
|
||||
console.error('Invalid roadmap frontmatter');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (roadmapFrontmatter.renderer !== 'editor') {
|
||||
console.error('Only Editor Rendered Roadmaps are allowed');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Launching chromium`);
|
||||
const browser = await playwright.chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const pageUrl = `http://localhost:3000/${roadmapId}/svg`;
|
||||
console.log(`Opening page ${pageUrl}`);
|
||||
await page.goto(pageUrl);
|
||||
await page.waitForSelector('#resource-svg-wrap');
|
||||
await page.waitForTimeout(5000);
|
||||
console.log(`Generating PDF ${pageUrl}`);
|
||||
await page.pdf({
|
||||
path: `./public/pdfs/roadmaps/${roadmapId}.pdf`,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
height: roadmapFrontmatter?.dimensions?.height || 2000,
|
||||
width: roadmapFrontmatter?.dimensions?.width || 968,
|
||||
});
|
||||
|
||||
// @todo generate png from the pdf
|
||||
console.log(`Generating png ${pageUrl}`);
|
||||
await page.locator('#resource-svg-wrap>svg').screenshot({
|
||||
path: `./public/roadmaps/${roadmapId}.png`,
|
||||
type: 'png',
|
||||
scale: 'device',
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
185
scripts/editor-roadmap-content.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Edge, Node } from 'reactflow';
|
||||
import matter from 'gray-matter';
|
||||
import type { RoadmapFrontmatter } from '../src/lib/roadmap';
|
||||
import { slugify } from '../src/lib/slugger';
|
||||
import OpenAI from 'openai';
|
||||
import { runPromisesInBatchSequentially } from '../src/lib/promise';
|
||||
|
||||
// ERROR: `__dirname` is not defined in ES module scope
|
||||
// https://iamwebwiz.medium.com/how-to-fix-dirname-is-not-defined-in-es-module-scope-34d94a86694d
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Usage: tsx ./scripts/editor-roadmap-content.ts <roadmapId>
|
||||
const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY;
|
||||
console.log('OPEN_AI_API_KEY:', OPEN_AI_API_KEY);
|
||||
const ROADMAP_CONTENT_DIR = path.join(__dirname, '../src/data/roadmaps');
|
||||
const roadmapId = process.argv[2];
|
||||
|
||||
const allowedRoadmapIds = await fs.readdir(ROADMAP_CONTENT_DIR);
|
||||
if (!roadmapId) {
|
||||
console.error('Roadmap Id is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!allowedRoadmapIds.includes(roadmapId)) {
|
||||
console.error(`Invalid roadmap key ${roadmapId}`);
|
||||
console.error(`Allowed keys are ${allowedRoadmapIds.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roadmapFrontmatterDir = path.join(
|
||||
ROADMAP_CONTENT_DIR,
|
||||
roadmapId,
|
||||
`${roadmapId}.md`,
|
||||
);
|
||||
const roadmapFrontmatterRaw = await fs.readFile(roadmapFrontmatterDir, 'utf-8');
|
||||
const { data } = matter(roadmapFrontmatterRaw);
|
||||
|
||||
const roadmapFrontmatter = data as RoadmapFrontmatter;
|
||||
if (!roadmapFrontmatter) {
|
||||
console.error('Invalid roadmap frontmatter');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (roadmapFrontmatter.renderer !== 'editor') {
|
||||
console.error('Only Editor Rendered Roadmaps are allowed');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roadmapDir = path.join(
|
||||
ROADMAP_CONTENT_DIR,
|
||||
roadmapId,
|
||||
`${roadmapId}.json`,
|
||||
);
|
||||
const roadmapContent = await fs.readFile(roadmapDir, 'utf-8');
|
||||
let { nodes, edges } = JSON.parse(roadmapContent) as {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
};
|
||||
const enrichedNodes = nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
node?.type &&
|
||||
['topic', 'subtopic'].includes(node.type) &&
|
||||
node.data?.label,
|
||||
)
|
||||
.map((node) => {
|
||||
// Because we only need the parent id and title for subtopics
|
||||
if (node.type !== 'subtopic') {
|
||||
return node;
|
||||
}
|
||||
|
||||
const parentNodeId =
|
||||
edges.find((edge) => edge.target === node.id)?.source || '';
|
||||
const parentNode = nodes.find((n) => n.id === parentNodeId);
|
||||
|
||||
return {
|
||||
...node,
|
||||
parentId: parentNodeId,
|
||||
parentTitle: parentNode?.data?.label || '',
|
||||
};
|
||||
}) as (Node & { parentId?: string; parentTitle?: string })[];
|
||||
|
||||
const roadmapContentDir = path.join(ROADMAP_CONTENT_DIR, roadmapId, 'content');
|
||||
const stats = await fs.stat(roadmapContentDir).catch(() => null);
|
||||
if (!stats || !stats.isDirectory()) {
|
||||
await fs.mkdir(roadmapContentDir, { recursive: true });
|
||||
}
|
||||
|
||||
let openai: OpenAI | undefined;
|
||||
if (OPEN_AI_API_KEY) {
|
||||
openai = new OpenAI({
|
||||
apiKey: OPEN_AI_API_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
function writeTopicContent(
|
||||
roadmapTitle: string,
|
||||
childTopic: string,
|
||||
parentTopic?: string,
|
||||
) {
|
||||
let prompt = `I will give you a topic and you need to write a brief introduction for that with regards to "${roadmapTitle}". Your format should be as follows and be in strictly markdown format:
|
||||
|
||||
# (Put a heading for the topic without adding parent "Subtopic in Topic" or "Topic in Roadmap" or "Subtopic under XYZ" etc.)
|
||||
|
||||
(Briefly explain the topic in one paragraph using simple english with regards to "${roadmapTitle}". Don't start with explaining how important the topic is with regard to "${roadmapTitle}". Don't say something along the lines of "XYZ plays a crucial role in ${roadmapTitle}". Don't include anything saying "In the context of ${roadmapTitle}". Instead, start with a simple explanation of the topic itself. For example, if the topic is "React", you can start with "React is a JavaScript library for building user interfaces." and then you can explain how it is used in "${roadmapTitle}".)
|
||||
`;
|
||||
|
||||
if (!parentTopic) {
|
||||
prompt += `First topic is: ${childTopic}`;
|
||||
} else {
|
||||
prompt += `First topic is: ${childTopic} under ${parentTopic}`;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
openai?.chat.completions
|
||||
.create({
|
||||
model: 'gpt-4',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((response) => {
|
||||
const article = response.choices[0].message.content;
|
||||
|
||||
resolve(article);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function writeNodeContent(node: Node & { parentTitle?: string }) {
|
||||
const nodeDirPattern = `${slugify(node.data.label)}@${node.id}.md`;
|
||||
if (!roadmapContentFiles.includes(nodeDirPattern)) {
|
||||
console.log(`Missing file for: ${nodeDirPattern}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeDir = path.join(roadmapContentDir, nodeDirPattern);
|
||||
const nodeContent = await fs.readFile(nodeDir, 'utf-8');
|
||||
const isFileEmpty = !nodeContent.replace(`# ${node.data.label}`, '').trim();
|
||||
if (!isFileEmpty) {
|
||||
console.log(`❌ Ignoring ${nodeDirPattern}. Not empty.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const topic = node.data.label;
|
||||
const parentTopic = node.parentTitle;
|
||||
|
||||
console.log(`⏳ Generating content for ${topic}...`);
|
||||
let newContentFile = '';
|
||||
if (OPEN_AI_API_KEY) {
|
||||
newContentFile = (await writeTopicContent(
|
||||
roadmapFrontmatter.title,
|
||||
topic,
|
||||
parentTopic,
|
||||
)) as string;
|
||||
} else {
|
||||
newContentFile = `# ${topic}`;
|
||||
}
|
||||
|
||||
await fs.writeFile(nodeDir, newContentFile, 'utf-8');
|
||||
console.log(`✅ Content generated for ${topic}`);
|
||||
}
|
||||
|
||||
let roadmapContentFiles = await fs.readdir(roadmapContentDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
if (!OPEN_AI_API_KEY) {
|
||||
console.log('----------------------------------------');
|
||||
console.log('OPEN_AI_API_KEY not found. Skipping openai api calls...');
|
||||
console.log('----------------------------------------');
|
||||
}
|
||||
const promises = enrichedNodes.map((node) => () => writeNodeContent(node));
|
||||
await runPromisesInBatchSequentially(promises, 20);
|
||||
console.log('✅ All content generated');
|
||||
86
scripts/editor-roadmap-dirs.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Node } from 'reactflow';
|
||||
import matter from 'gray-matter';
|
||||
import type { RoadmapFrontmatter } from '../src/lib/roadmap';
|
||||
import { slugify } from '../src/lib/slugger';
|
||||
|
||||
// ERROR: `__dirname` is not defined in ES module scope
|
||||
// https://iamwebwiz.medium.com/how-to-fix-dirname-is-not-defined-in-es-module-scope-34d94a86694d
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Usage: tsx ./scripts/editor-roadmap-dirs.ts <roadmapId>
|
||||
|
||||
// Directory containing the roadmaps
|
||||
const ROADMAP_CONTENT_DIR = path.join(__dirname, '../src/data/roadmaps');
|
||||
const roadmapId = process.argv[2];
|
||||
|
||||
const allowedRoadmapIds = await fs.readdir(ROADMAP_CONTENT_DIR);
|
||||
if (!roadmapId) {
|
||||
console.error('Roadmap Id is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!allowedRoadmapIds.includes(roadmapId)) {
|
||||
console.error(`Invalid roadmap key ${roadmapId}`);
|
||||
console.error(`Allowed keys are ${allowedRoadmapIds.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roadmapFrontmatterDir = path.join(
|
||||
ROADMAP_CONTENT_DIR,
|
||||
roadmapId,
|
||||
`${roadmapId}.md`,
|
||||
);
|
||||
const roadmapFrontmatterRaw = await fs.readFile(roadmapFrontmatterDir, 'utf-8');
|
||||
const { data } = matter(roadmapFrontmatterRaw);
|
||||
|
||||
const roadmapFrontmatter = data as RoadmapFrontmatter;
|
||||
if (!roadmapFrontmatter) {
|
||||
console.error('Invalid roadmap frontmatter');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (roadmapFrontmatter.renderer !== 'editor') {
|
||||
console.error('Only Editor Rendered Roadmaps are allowed');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roadmapDir = path.join(
|
||||
ROADMAP_CONTENT_DIR,
|
||||
roadmapId,
|
||||
`${roadmapId}.json`,
|
||||
);
|
||||
const roadmapContent = await fs.readFile(roadmapDir, 'utf-8');
|
||||
let { nodes } = JSON.parse(roadmapContent) as {
|
||||
nodes: Node[];
|
||||
};
|
||||
nodes = nodes.filter(
|
||||
(node) =>
|
||||
node?.type && ['topic', 'subtopic'].includes(node.type) && node.data?.label,
|
||||
);
|
||||
|
||||
const roadmapContentDir = path.join(ROADMAP_CONTENT_DIR, roadmapId, 'content');
|
||||
const stats = await fs.stat(roadmapContentDir).catch(() => null);
|
||||
if (!stats || !stats.isDirectory()) {
|
||||
await fs.mkdir(roadmapContentDir, { recursive: true });
|
||||
}
|
||||
|
||||
const roadmapContentFiles = await fs.readdir(roadmapContentDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
nodes.forEach(async (node, index) => {
|
||||
const nodeDirPattern = `${slugify(node.data.label)}@${node.id}.md`;
|
||||
if (roadmapContentFiles.includes(nodeDirPattern)) {
|
||||
console.log(`Skipping ${nodeDirPattern}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(roadmapContentDir, nodeDirPattern),
|
||||
`# ${node.data.label}`,
|
||||
);
|
||||
});
|
||||
@@ -475,8 +475,6 @@ function getRoadmapDefaultTemplate({ title, description }) {
|
||||
|
||||
function getRoadmapImageTemplate({ title, description, image, height, width }) {
|
||||
return html`<div tw="bg-white relative flex flex-col h-full w-full">
|
||||
<div tw="absolute flex top-0 left-0 w-full h-[18px] bg-black"></div>
|
||||
|
||||
<div tw="flex flex-col px-[90px] pt-[90px]">
|
||||
<div tw="flex flex-col pb-0">
|
||||
<div tw="text-[70px] leading-[70px] tracking-tight">
|
||||
|
||||
@@ -29,4 +29,6 @@ done
|
||||
|
||||
|
||||
# ignore the worktree changes for the editor directory
|
||||
git update-index --assume-unchanged editor/readonly-editor.tsx || true
|
||||
git update-index --assume-unchanged editor/readonly-editor.tsx || true
|
||||
git update-index --assume-unchanged editor/renderer/index.tsx || true
|
||||
git update-index --assume-unchanged editor/renderer/renderer.ts || true
|
||||
|
||||
41
scripts/label-issues.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Fetch issues JSON data and parse it properly
|
||||
issues=$(gh issue list --repo kamranahmedse/developer-roadmap --search "sort:created-asc" --state open --limit 500 --json number,title,createdAt,updatedAt,state,url,comments,reactionGroups,body | jq -c '.[]')
|
||||
|
||||
# checks the body of issue, identifies the slug from the roadmap URLs
|
||||
# and labels the issue with the corresponding slug
|
||||
while IFS= read -r issue; do
|
||||
created_at=$(echo "$issue" | jq -r '.createdAt')
|
||||
updated_at=$(echo "$issue" | jq -r '.updatedAt')
|
||||
issue_number=$(echo "$issue" | jq -r '.number')
|
||||
issue_title=$(echo "$issue" | jq -r '.title')
|
||||
reaction_groups=$(echo "$issue" | jq -r '.reactionGroups')
|
||||
has_reactions=$(echo "$issue" | jq -r '.reactionGroups | length')
|
||||
comment_count=$(echo "$issue" | jq -r '.comments | length')
|
||||
body_characters=$(echo "$issue" | jq -r '.body | length')
|
||||
|
||||
# If the issue has no body, then skip it
|
||||
if [ "$body_characters" -eq 0 ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract the roadmap URLs from the issue body
|
||||
roadmap_urls=$(echo "$issue" | jq -r '.body' | grep -o 'https://roadmap\.sh/[^ ]*')
|
||||
|
||||
# If no roadmap URLs found, then skip it
|
||||
if [ -z "$roadmap_urls" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# URL is like https://roadmap.sh/frontend
|
||||
# Extract the slug from the URL
|
||||
slug_of_first_url=$(echo "$roadmap_urls" | head -n 1 | sed 's/https:\/\/roadmap\.sh\///')
|
||||
|
||||
if [ -z "$slug_of_first_url" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Label the issue with the slug
|
||||
gh issue edit "$issue_number" --add-label "$slug_of_first_url"
|
||||
done <<< "$issues"
|
||||
45
scripts/warm-urls.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Usage: warm-urls.sh <sitemap-url>
|
||||
# Example: warm-urls.sh https://www.example.com/sitemap.xml
|
||||
|
||||
# Check if sitemap url is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo "Please provide sitemap URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get all URLs from sitemap
|
||||
urls=$(curl -s "$1" | grep -o "<loc>[^<]*</loc>" | sed 's#<loc>\(.*\)</loc>#\1#')
|
||||
|
||||
failed_urls=()
|
||||
|
||||
# Warm up URLs
|
||||
for url in $urls; do
|
||||
# Fetch the og:image URL from the meta tags
|
||||
og_image_url=$(curl -s "$url" | grep -o "<meta property=\"og:image\" content=\"[^\"]*\"" | sed 's#<meta property="og:image" content="\([^"]*\)"#\1#')
|
||||
|
||||
# warm the URL
|
||||
echo "Warming up URL: $url"
|
||||
if ! curl -s -I "$url" > /dev/null; then
|
||||
failed_urls+=("$url")
|
||||
fi
|
||||
|
||||
# Warm up the og:image URL
|
||||
if [ -n "$og_image_url" ]; then
|
||||
echo "Warming up OG: $og_image_url"
|
||||
if ! curl -s -I "$og_image_url" > /dev/null; then
|
||||
failed_urls+=("$og_image_url")
|
||||
fi
|
||||
else
|
||||
echo "No og:image found for $url"
|
||||
fi
|
||||
done
|
||||
|
||||
# Print failed URLs
|
||||
if [ ${#failed_urls[@]} -gt 0 ]; then
|
||||
echo "Failed to warm up the following URLs:" >&2
|
||||
for failed_url in "${failed_urls[@]}"; do
|
||||
echo "$failed_url" >&2
|
||||
done
|
||||
fi
|
||||
@@ -18,6 +18,9 @@ export const allowedProfileVisibility = ['public', 'private'] as const;
|
||||
export type AllowedProfileVisibility =
|
||||
(typeof allowedProfileVisibility)[number];
|
||||
|
||||
export const allowedOnboardingStatus = ['done', 'pending', 'ignored'] as const;
|
||||
export type AllowedOnboardingStatus = (typeof allowedOnboardingStatus)[number];
|
||||
|
||||
export interface UserDocument {
|
||||
_id?: string;
|
||||
name: string;
|
||||
@@ -41,6 +44,7 @@ export interface UserDocument {
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
twitter?: string;
|
||||
dailydev?: string;
|
||||
website?: string;
|
||||
};
|
||||
username?: string;
|
||||
@@ -56,6 +60,18 @@ export interface UserDocument {
|
||||
};
|
||||
resetPasswordCodeAt: string;
|
||||
verifiedAt: string;
|
||||
|
||||
// Onboarding fields
|
||||
onboardingStatus?: AllowedOnboardingStatus;
|
||||
onboarding?: {
|
||||
updateProgress: AllowedOnboardingStatus;
|
||||
publishProfile: AllowedOnboardingStatus;
|
||||
customRoadmap: AllowedOnboardingStatus;
|
||||
addFriends: AllowedOnboardingStatus;
|
||||
roadCard: AllowedOnboardingStatus;
|
||||
inviteTeam: AllowedOnboardingStatus;
|
||||
};
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { EmptyStream } from './EmptyStream';
|
||||
import { ActivityTopicsModal } from './ActivityTopicsModal.tsx';
|
||||
import {Book, BookOpen, ChevronsDown, ChevronsDownUp, ChevronsUp, ChevronsUpDown} from 'lucide-react';
|
||||
import { ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||
import { ActivityTopicTitles } from './ActivityTopicTitles.tsx';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
export const allowedActivityActionType = [
|
||||
'in_progress',
|
||||
@@ -21,31 +23,39 @@ export type UserStreamActivity = {
|
||||
resourceSlug?: string;
|
||||
isCustomResource?: boolean;
|
||||
actionType: AllowedActivityActionType;
|
||||
topicIds?: string[];
|
||||
topicTitles?: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type ActivityStreamProps = {
|
||||
activities: UserStreamActivity[];
|
||||
className?: string;
|
||||
onResourceClick?: (
|
||||
resourceId: string,
|
||||
resourceType: ResourceType,
|
||||
isCustomResource: boolean,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function ActivityStream(props: ActivityStreamProps) {
|
||||
const { activities } = props;
|
||||
const { activities, className, onResourceClick } = props;
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [selectedActivity, setSelectedActivity] =
|
||||
useState<UserStreamActivity | null>(null);
|
||||
|
||||
const sortedActivities = activities
|
||||
.filter((activity) => activity?.topicIds && activity.topicIds.length > 0)
|
||||
.filter(
|
||||
(activity) => activity?.topicTitles && activity.topicTitles.length > 0,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
})
|
||||
.slice(0, showAll ? activities.length : 10);
|
||||
|
||||
return (
|
||||
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
<div className={cn('mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8', className)}>
|
||||
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||
Learning Activity
|
||||
</h2>
|
||||
@@ -57,8 +67,8 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
resourceId={selectedActivity.resourceId}
|
||||
resourceType={selectedActivity.resourceType}
|
||||
isCustomResource={selectedActivity.isCustomResource}
|
||||
topicIds={selectedActivity.topicIds || []}
|
||||
topicCount={selectedActivity.topicIds?.length || 0}
|
||||
topicTitles={selectedActivity.topicTitles || []}
|
||||
topicCount={selectedActivity.topicTitles?.length || 0}
|
||||
actionType={selectedActivity.actionType}
|
||||
/>
|
||||
)}
|
||||
@@ -73,8 +83,9 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
resourceTitle,
|
||||
actionType,
|
||||
updatedAt,
|
||||
topicIds,
|
||||
topicTitles,
|
||||
isCustomResource,
|
||||
resourceSlug,
|
||||
} = activity;
|
||||
|
||||
const resourceUrl =
|
||||
@@ -83,20 +94,30 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
: resourceType === 'best-practice'
|
||||
? `/best-practices/${resourceId}`
|
||||
: isCustomResource && resourceType === 'roadmap'
|
||||
? `/r/${resourceId}`
|
||||
? `/r/${resourceSlug}`
|
||||
: `/${resourceId}`;
|
||||
|
||||
const resourceLinkComponent = (
|
||||
<a
|
||||
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
|
||||
target="_blank"
|
||||
href={resourceUrl}
|
||||
>
|
||||
{resourceTitle}
|
||||
</a>
|
||||
);
|
||||
const resourceLinkComponent =
|
||||
onResourceClick && resourceType !== 'question' ? (
|
||||
<button
|
||||
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
|
||||
onClick={() =>
|
||||
onResourceClick(resourceId, resourceType, isCustomResource!)
|
||||
}
|
||||
>
|
||||
{resourceTitle}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
|
||||
target="_blank"
|
||||
href={resourceUrl}
|
||||
>
|
||||
{resourceTitle}
|
||||
</a>
|
||||
);
|
||||
|
||||
const topicCount = topicIds?.length || 0;
|
||||
const topicCount = topicTitles?.length || 0;
|
||||
|
||||
const timeAgo = (
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
@@ -108,32 +129,35 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
<li key={_id} className="py-2 text-sm text-gray-600">
|
||||
{actionType === 'in_progress' && (
|
||||
<>
|
||||
Started{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => setSelectedActivity(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
in {resourceLinkComponent} {timeAgo}
|
||||
<p className="mb-1">
|
||||
Started {topicCount} topic
|
||||
{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLinkComponent}
|
||||
{timeAgo}
|
||||
</p>
|
||||
<ActivityTopicTitles topicTitles={topicTitles || []} />
|
||||
</>
|
||||
)}
|
||||
{actionType === 'done' && (
|
||||
<>
|
||||
Completed{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => setSelectedActivity(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
in {resourceLinkComponent} {timeAgo}
|
||||
<p className="mb-1">
|
||||
Completed {topicCount} topic
|
||||
{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLinkComponent}
|
||||
{timeAgo}
|
||||
</p>
|
||||
<ActivityTopicTitles topicTitles={topicTitles || []} />
|
||||
</>
|
||||
)}
|
||||
{actionType === 'answered' && (
|
||||
<>
|
||||
Answered {topicCount} question{topicCount > 1 ? 's' : ''} in{' '}
|
||||
{resourceLinkComponent} {timeAgo}
|
||||
<p className="mb-1">
|
||||
Answered {topicCount} question
|
||||
{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLinkComponent}
|
||||
{timeAgo}
|
||||
</p>
|
||||
<ActivityTopicTitles topicTitles={topicTitles || []} />
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
@@ -146,16 +170,20 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
|
||||
{activities.length > 10 && (
|
||||
<button
|
||||
className="mt-3 gap-2 flex items-center rounded-md border border-black pl-1.5 pr-2 py-1 text-xs uppercase tracking-wide text-black transition-colors hover:border-black hover:bg-black hover:text-white"
|
||||
className="mt-3 flex items-center gap-2 rounded-md border border-black py-1 pl-1.5 pr-2 text-xs uppercase tracking-wide text-black transition-colors hover:border-black hover:bg-black hover:text-white"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
>
|
||||
{showAll ? <>
|
||||
<ChevronsUp size={14} />
|
||||
Show less
|
||||
</> : <>
|
||||
<ChevronsDown size={14} />
|
||||
Show more
|
||||
</>}
|
||||
{showAll ? (
|
||||
<>
|
||||
<ChevronsUp size={14} />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronsDown size={14} />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
43
src/components/Activity/ActivityTopicTitles.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type ActivityTopicTitlesProps = {
|
||||
topicTitles: string[];
|
||||
className?: string;
|
||||
onSelectActivity?: () => void;
|
||||
};
|
||||
|
||||
export function ActivityTopicTitles(props: ActivityTopicTitlesProps) {
|
||||
const { topicTitles, onSelectActivity, className } = props;
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const filteredTopicTitles = topicTitles.slice(
|
||||
0,
|
||||
showAll ? topicTitles.length : 3,
|
||||
);
|
||||
|
||||
const shouldShowButton = topicTitles.length > 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap gap-1 text-sm font-normal text-gray-600',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{filteredTopicTitles.map((topicTitle, index) => (
|
||||
<span key={index} className="rounded-md bg-gray-200 px-1.5">
|
||||
{topicTitle}
|
||||
</span>
|
||||
))}
|
||||
{shouldShowButton && !showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="bg-white border border-black text-black rounded-md px-1.5 hover:bg-black text-xs h-[20px] hover:text-white"
|
||||
>
|
||||
{showAll ? '- Show less' : `+${topicTitles.length - 3}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type ActivityTopicDetailsProps = {
|
||||
resourceId: string;
|
||||
resourceType: ResourceType | 'question';
|
||||
isCustomResource?: boolean;
|
||||
topicIds: string[];
|
||||
topicTitles: string[];
|
||||
topicCount: number;
|
||||
actionType: AllowedActivityActionType;
|
||||
onClose: () => void;
|
||||
@@ -22,56 +22,12 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
topicIds = [],
|
||||
topicTitles = [],
|
||||
topicCount,
|
||||
actionType,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTopicTitles = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`,
|
||||
{
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
topicIds,
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Failed to load topic titles');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTopicTitles(response);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTopicTitles().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading || error) {
|
||||
return (
|
||||
<ModalLoader
|
||||
error={error!}
|
||||
text={'Loading topics..'}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let pageUrl = '';
|
||||
if (resourceType === 'roadmap') {
|
||||
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`;
|
||||
@@ -85,8 +41,6 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
|
||||
<Modal
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}}
|
||||
>
|
||||
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}>
|
||||
@@ -108,9 +62,7 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
|
||||
</a>
|
||||
</span>
|
||||
<ul className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto max-md:max-h-full">
|
||||
{topicIds.map((topicId) => {
|
||||
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
|
||||
|
||||
{topicTitles.map((topicTitle) => {
|
||||
const ActivityIcon =
|
||||
actionType === 'done'
|
||||
? Check
|
||||
@@ -119,7 +71,7 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
|
||||
: Check;
|
||||
|
||||
return (
|
||||
<li key={topicId} className="flex items-start gap-2">
|
||||
<li key={topicTitle} className="flex items-start gap-2">
|
||||
<ActivityIcon
|
||||
strokeWidth={3}
|
||||
className="relative top-[4px] text-green-500"
|
||||
|
||||
@@ -4,7 +4,7 @@ export function EmptyStream() {
|
||||
return (
|
||||
<div className="rounded-md">
|
||||
<div className="flex flex-col items-center p-7 text-center">
|
||||
<List className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
|
||||
<List className="mb-4 h-[60px] w-[60px] opacity-10 sm:h-[60px] sm:w-[60px]" />
|
||||
|
||||
<h2 className="text-lg font-bold sm:text-xl">No Activities</h2>
|
||||
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getUser } from '../../lib/jwt';
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { ResourceProgressActions } from './ResourceProgressActions';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
@@ -15,10 +16,17 @@ type ResourceProgressType = {
|
||||
showClearButton?: boolean;
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
showActions?: boolean;
|
||||
onResourceClick?: () => void;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const { showClearButton = true, isCustomResource } = props;
|
||||
const {
|
||||
showClearButton = true,
|
||||
isCustomResource,
|
||||
showActions = true,
|
||||
onResourceClick,
|
||||
} = props;
|
||||
|
||||
const userId = getUser()?.id;
|
||||
|
||||
@@ -47,12 +55,23 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = getPercentage(totalMarked, totalCount);
|
||||
|
||||
const Slot = onResourceClick ? 'button' : 'a';
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<a
|
||||
target="_blank"
|
||||
href={url}
|
||||
className="group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 pr-7 text-left text-sm transition-all hover:border-gray-400"
|
||||
<Slot
|
||||
{...(onResourceClick
|
||||
? {
|
||||
onClick: onResourceClick,
|
||||
}
|
||||
: {
|
||||
href: url,
|
||||
target: '_blank',
|
||||
})}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400',
|
||||
showActions ? 'pr-7' : '',
|
||||
)}
|
||||
>
|
||||
<span className="flex-grow truncate">{title}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
@@ -65,18 +84,20 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
></span>
|
||||
</a>
|
||||
</Slot>
|
||||
|
||||
<div className="absolute right-2 top-0 flex h-full items-center">
|
||||
<ResourceProgressActions
|
||||
userId={userId!}
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
onCleared={onCleared}
|
||||
showClearButton={showClearButton}
|
||||
/>
|
||||
</div>
|
||||
{showActions && (
|
||||
<div className="absolute right-2 top-0 flex h-full items-center">
|
||||
<ResourceProgressActions
|
||||
userId={userId!}
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
onCleared={onCleared}
|
||||
showClearButton={showClearButton}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ function handleGuest() {
|
||||
'/team/roadmaps',
|
||||
'/team/new',
|
||||
'/team/members',
|
||||
'/team/member',
|
||||
'/team/settings',
|
||||
];
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ const isBestPracticeReady = !isUpcoming;
|
||||
{
|
||||
isBestPracticeReady && (
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new/choose`}
|
||||
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"
|
||||
|
||||
@@ -17,6 +17,8 @@ import { ClipboardIcon } from '../ReactIcons/ClipboardIcon.tsx';
|
||||
import { GuideIcon } from '../ReactIcons/GuideIcon.tsx';
|
||||
import { HomeIcon } from '../ReactIcons/HomeIcon.tsx';
|
||||
import { VideoIcon } from '../ReactIcons/VideoIcon.tsx';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
|
||||
|
||||
export type PageType = {
|
||||
id: string;
|
||||
@@ -26,6 +28,7 @@ export type PageType = {
|
||||
icon?: ReactElement;
|
||||
isProtected?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
@@ -190,7 +193,7 @@ export function CommandMenu() {
|
||||
|
||||
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 top-0 h-full w-full max-w-lg p-2 sm:mt-20 md:h-auto">
|
||||
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -245,9 +248,10 @@ export function CommandMenu() {
|
||||
<div className="border-b border-gray-100"></div>
|
||||
)}
|
||||
<a
|
||||
className={`flex w-full items-center rounded p-2 text-sm ${
|
||||
counter === activeCounter ? 'bg-gray-100' : ''
|
||||
}`}
|
||||
className={cn(
|
||||
'flex w-full items-center rounded p-2 text-sm',
|
||||
counter === activeCounter ? 'bg-gray-100' : '',
|
||||
)}
|
||||
onMouseOver={() => setActiveCounter(counter)}
|
||||
href={page.url}
|
||||
>
|
||||
|
||||
@@ -24,6 +24,7 @@ export type TeamResourceConfig = {
|
||||
topics?: number;
|
||||
sharedTeamMemberIds: string[];
|
||||
sharedFriendIds: string[];
|
||||
defaultRoadmapId?: string;
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
@@ -106,6 +107,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Adding roadmap to team`);
|
||||
const renderer = allRoadmaps.find((r) => r.id === roadmapId)?.renderer;
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
@@ -115,6 +117,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
renderer: renderer || 'balsamiq',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -124,6 +127,9 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
setTeamResources(response);
|
||||
if (renderer === 'editor') {
|
||||
setShowSelectRoadmapModal(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -68,7 +68,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="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 className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="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 className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
|
||||
<div
|
||||
id={'customized-roadmap'}
|
||||
|
||||
@@ -15,6 +15,10 @@ export const allowedLinkTypes = [
|
||||
'course',
|
||||
'website',
|
||||
'podcast',
|
||||
'roadmap.sh',
|
||||
'official',
|
||||
'roadmap',
|
||||
'feed'
|
||||
] as const;
|
||||
|
||||
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
|
||||
|
||||
@@ -62,7 +62,10 @@ export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
|
||||
}
|
||||
|
||||
const handleTopicRightClick = useCallback((e: MouseEvent, node: Node) => {
|
||||
const target = e?.currentTarget as HTMLDivElement;
|
||||
const target =
|
||||
node?.type === 'todo'
|
||||
? document.querySelector(`[data-id="${node.id}"]`)
|
||||
: (e?.currentTarget as HTMLDivElement);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
15
src/components/DailyDevIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function DailyDevIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 32 18" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g fill="currentColor" fillRule="nonzero">
|
||||
<path
|
||||
d="M26.633 8.69l-3.424-3.431 1.711-3.43 5.563 5.575c.709.71.709 1.861 0 2.572l-6.847 6.86c-.709.711-1.858.711-2.567 0a1.821 1.821 0 010-2.571l5.564-5.575z"
|
||||
fillOpacity="0.64"
|
||||
></path>
|
||||
<path d="M21.07.536a1.813 1.813 0 012.568 0l1.283 1.286L9.945 16.83c-.709.71-1.858.71-2.567 0l-1.284-1.287L21.071.536zm-6.418 4.717l-2.567 2.572-3.424-3.43-4.28 4.288 3.424 3.43-1.71 3.43L.531 9.97a1.821 1.821 0 010-2.572L7.378.537A1.813 1.813 0 019.945.535l4.707 4.717z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
106
src/components/EditorRoadmap/EditorRoadmap.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
EditorRoadmapRenderer,
|
||||
type RoadmapRendererProps,
|
||||
} from './EditorRoadmapRenderer';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import {
|
||||
clearMigratedRoadmapProgress,
|
||||
type ResourceType,
|
||||
} from '../../lib/resource-progress';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { ProgressNudge } from '../FrameRenderer/ProgressNudge';
|
||||
import { getUrlParams } from '../../lib/browser.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { getUser } from '../../lib/jwt.ts';
|
||||
|
||||
type EditorRoadmapProps = {
|
||||
resourceId: string;
|
||||
resourceType?: ResourceType;
|
||||
dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function EditorRoadmap(props: EditorRoadmapProps) {
|
||||
const { resourceId, resourceType = 'roadmap', dimensions } = props;
|
||||
|
||||
const [hasSwitchedRoadmap, setHasSwitchedRoadmap] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [roadmapData, setRoadmapData] = useState<
|
||||
Omit<RoadmapRendererProps, 'resourceId'> | undefined
|
||||
>(undefined);
|
||||
|
||||
const loadRoadmapData = async () => {
|
||||
setIsLoading(true);
|
||||
const { r: switchRoadmapId } = getUrlParams();
|
||||
|
||||
const { response, error } = await httpGet<
|
||||
Omit<RoadmapRendererProps, 'resourceId'>
|
||||
>(`/${switchRoadmapId || resourceId}.json`);
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setRoadmapData(response);
|
||||
setIsLoading(false);
|
||||
setHasSwitchedRoadmap(!!switchRoadmapId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
clearMigratedRoadmapProgress(resourceType, resourceId);
|
||||
loadRoadmapData().finally();
|
||||
}, [resourceId]);
|
||||
|
||||
const aspectRatio = dimensions.width / dimensions.height;
|
||||
|
||||
if (!roadmapData || isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
!hasSwitchedRoadmap
|
||||
? ({
|
||||
'--aspect-ratio': aspectRatio,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
className={
|
||||
'flex aspect-[var(--aspect-ratio)] w-full flex-col justify-center'
|
||||
}
|
||||
>
|
||||
<div className="flex w-full justify-center">
|
||||
<Spinner
|
||||
innerFill="#2563eb"
|
||||
outerFill="#E5E7EB"
|
||||
className="h-6 w-6 animate-spin sm:h-12 sm:w-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
!hasSwitchedRoadmap
|
||||
? ({
|
||||
'--aspect-ratio': aspectRatio,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
className={
|
||||
'flex aspect-[var(--aspect-ratio)] w-full flex-col justify-center'
|
||||
}
|
||||
>
|
||||
<EditorRoadmapRenderer
|
||||
{...roadmapData}
|
||||
dimensions={dimensions}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
<ProgressNudge resourceId={resourceId} resourceType={resourceType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/EditorRoadmap/EditorRoadmapRenderer.css
Normal file
@@ -0,0 +1,63 @@
|
||||
svg text tspan {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic'],
|
||||
svg > g[data-type='subtopic'],
|
||||
svg g[data-type='link-item'],
|
||||
svg > g[data-type='button'],
|
||||
svg > g[data-type='resourceButton'],
|
||||
svg > g[data-type='todo-checkbox'],
|
||||
svg > g[data-type='todo'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic']:hover > rect {
|
||||
fill: var(--hover-color);
|
||||
}
|
||||
|
||||
svg > g[data-type='subtopic']:hover > rect {
|
||||
fill: var(--hover-color);
|
||||
}
|
||||
svg g[data-type='button']:hover,
|
||||
svg g[data-type='link-item']:hover,
|
||||
svg g[data-type='resourceButton']:hover,
|
||||
svg g[data-type='todo-checkbox']:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
svg .done rect {
|
||||
fill: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done text,
|
||||
svg .skipped text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic'].learning > rect + text,
|
||||
svg > g[data-type='topic'].done > rect + text {
|
||||
fill: black;
|
||||
}
|
||||
|
||||
svg .done text[fill='#ffffff'] {
|
||||
fill: black;
|
||||
}
|
||||
|
||||
svg > g[data-type='subtipic'].done > rect + text,
|
||||
svg > g[data-type='subtipic'].learning > rect + text {
|
||||
fill: #cbcbcb;
|
||||
}
|
||||
|
||||
svg .learning rect {
|
||||
fill: #dad1fd !important;
|
||||
}
|
||||
svg .learning text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
svg .skipped rect {
|
||||
fill: #496b69 !important;
|
||||
}
|
||||
218
src/components/EditorRoadmap/EditorRoadmapRenderer.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import './EditorRoadmapRenderer.css';
|
||||
import {
|
||||
renderResourceProgress,
|
||||
updateResourceProgress,
|
||||
type ResourceProgressType,
|
||||
renderTopicProgress,
|
||||
refreshProgressCounters,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { Edge, Node } from 'reactflow';
|
||||
import { Renderer } from '../../../editor/renderer';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
export type RoadmapRendererProps = {
|
||||
resourceId: string;
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
|
||||
type RoadmapNodeDetails = {
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
targetGroup: SVGElement;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
function getNodeDetails(svgElement: SVGElement): RoadmapNodeDetails | null {
|
||||
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
|
||||
|
||||
const nodeId = targetGroup?.dataset?.nodeId;
|
||||
const nodeType = targetGroup?.dataset?.type;
|
||||
const title = targetGroup?.dataset?.title;
|
||||
if (!nodeId || !nodeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { nodeId, nodeType, targetGroup, title };
|
||||
}
|
||||
|
||||
const allowedNodeTypes = [
|
||||
'topic',
|
||||
'subtopic',
|
||||
'button',
|
||||
'link-item',
|
||||
'resourceButton',
|
||||
'todo',
|
||||
'todo-checkbox',
|
||||
];
|
||||
|
||||
export function EditorRoadmapRenderer(props: RoadmapRendererProps) {
|
||||
const { resourceId, nodes = [], edges = [] } = props;
|
||||
const roadmapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
async function updateTopicStatus(
|
||||
topicId: string,
|
||||
newStatus: ResourceProgressType,
|
||||
) {
|
||||
pageProgressMessage.set('Updating progress');
|
||||
updateResourceProgress(
|
||||
{
|
||||
resourceId,
|
||||
resourceType: 'roadmap',
|
||||
topicId,
|
||||
},
|
||||
newStatus,
|
||||
)
|
||||
.then(() => {
|
||||
renderTopicProgress(topicId, newStatus);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Something went wrong, please try again.');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
refreshProgressCounters();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSvgClick = useCallback((e: MouseEvent) => {
|
||||
const target = e.target as SVGElement;
|
||||
const { nodeId, nodeType, targetGroup, title } =
|
||||
getNodeDetails(target) || {};
|
||||
|
||||
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
nodeType === 'button' ||
|
||||
nodeType === 'link-item' ||
|
||||
nodeType === 'resourceButton'
|
||||
) {
|
||||
const link = targetGroup?.dataset?.link || '';
|
||||
const isExternalLink = link.startsWith('http');
|
||||
if (isExternalLink) {
|
||||
window.open(link, '_blank');
|
||||
} else {
|
||||
window.location.href = link;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
|
||||
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
|
||||
|
||||
if (nodeType === 'todo-checkbox') {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const newStatus = targetGroup?.classList.contains('done')
|
||||
? 'pending'
|
||||
: 'done';
|
||||
updateTopicStatus(nodeId, newStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
updateTopicStatus(
|
||||
nodeId,
|
||||
isCurrentStatusLearning ? 'pending' : 'learning',
|
||||
);
|
||||
return;
|
||||
} else if (e.altKey) {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
const detailsPattern = `${slugify(title)}@${nodeId}`;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('roadmap.node.click', {
|
||||
detail: {
|
||||
topicId: detailsPattern,
|
||||
resourceId,
|
||||
resourceType: 'roadmap',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSvgRightClick = useCallback((e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target as SVGElement;
|
||||
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
|
||||
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodeType === 'button') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
const isCurrentStatusDone = targetGroup?.classList.contains('done');
|
||||
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roadmapRef?.current) {
|
||||
return;
|
||||
}
|
||||
roadmapRef?.current?.addEventListener('click', handleSvgClick);
|
||||
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
|
||||
|
||||
return () => {
|
||||
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
|
||||
roadmapRef?.current?.removeEventListener(
|
||||
'contextmenu',
|
||||
handleSvgRightClick,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Renderer
|
||||
ref={roadmapRef}
|
||||
roadmap={{ nodes, edges }}
|
||||
onRendered={() => {
|
||||
roadmapRef.current?.setAttribute('data-renderer', 'editor');
|
||||
renderResourceProgress('roadmap', resourceId).finally();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,47 @@
|
||||
---
|
||||
import type { GuideFileType } from '../lib/guide';
|
||||
import GuideListItem from './GuideListItem.astro';
|
||||
import { QuestionGroupType } from '../lib/question-group';
|
||||
|
||||
export interface Props {
|
||||
heading: string;
|
||||
guides: GuideFileType[];
|
||||
questions: QuestionGroupType[];
|
||||
}
|
||||
|
||||
const { heading, guides } = Astro.props;
|
||||
const { heading, guides, questions = [] } = Astro.props;
|
||||
|
||||
const sortedGuides: (QuestionGroupType | GuideFileType)[] = [
|
||||
...guides,
|
||||
...questions,
|
||||
].sort((a, b) => {
|
||||
const aDate = new Date(a.frontmatter.date);
|
||||
const bDate = new Date(b.frontmatter.date);
|
||||
|
||||
return bDate.getTime() - aDate.getTime();
|
||||
});
|
||||
---
|
||||
|
||||
<div class='container'>
|
||||
<h2 class='text-2xl sm:text-3xl font-bold block'>{heading}</h2>
|
||||
<h2 class='block text-2xl font-bold sm:text-3xl'>{heading}</h2>
|
||||
|
||||
<div class='mt-3 sm:my-5'>
|
||||
{guides.map((guide) => <GuideListItem guide={guide} />)}
|
||||
{sortedGuides.map((guide) => <GuideListItem guide={guide} />)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href='/guides'
|
||||
class='hidden sm:inline transition-colors py-2 px-3 text-xs font-medium rounded-full bg-gradient-to-r from-slate-600 to-black hover:from-blue-600 hover:to-blue-800 text-white'
|
||||
class='hidden rounded-full bg-gradient-to-r from-slate-600 to-black px-3 py-2 text-xs font-medium text-white transition-colors hover:from-blue-600 hover:to-blue-800 sm:inline'
|
||||
>
|
||||
View All Guides →
|
||||
</a>
|
||||
|
||||
<div class='block sm:hidden mt-3'>
|
||||
<div class='mt-3 block sm:hidden'>
|
||||
<a
|
||||
href='/guides'
|
||||
class='text-sm font-regular block p-2 border border-black text-black rounded-md text-center hover:bg-black hover:text-gray-50'
|
||||
class='font-regular block rounded-md border border-black p-2 text-center text-sm text-black hover:bg-black hover:text-gray-50'
|
||||
>
|
||||
View All Guides →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ svg .done rect {
|
||||
fill: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done rect[stroke="rgb(255,229,153)"] {
|
||||
svg .done rect[stroke='rgb(255,229,153)'] {
|
||||
stroke: #cbcbcb !important;
|
||||
}
|
||||
|
||||
@@ -133,10 +133,12 @@ svg .removed path {
|
||||
}
|
||||
}
|
||||
|
||||
#customized-roadmap #resource-svg-wrap g:not([class]),
|
||||
#customized-roadmap #resource-svg-wrap circle,
|
||||
#customized-roadmap #resource-svg-wrap path[stroke='#fff'],
|
||||
#customized-roadmap #resource-svg-wrap g[data-group-id$='-note'] {
|
||||
#customized-roadmap #resource-svg-wrap:not([data-renderer]) g:not([class]),
|
||||
#customized-roadmap #resource-svg-wrap:not([data-renderer]) circle,
|
||||
#customized-roadmap #resource-svg-wrap:not([data-renderer]) path[stroke='#fff'],
|
||||
#customized-roadmap
|
||||
#resource-svg-wrap:not([data-renderer])
|
||||
g[data-group-id$='-note'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ProgressNudge(props: ProgressNudgeProps) {
|
||||
<span className="relative -top-[0.45px] mr-2 text-xs font-medium uppercase text-yellow-400">
|
||||
Progress
|
||||
</span>
|
||||
<span>{done}</span> of <span>{$totalRoadmapNodes}</span> Done
|
||||
<span>{done > $totalRoadmapNodes ? $totalRoadmapNodes : done}</span> of <span>{$totalRoadmapNodes}</span> Done
|
||||
</span>
|
||||
|
||||
<span
|
||||
|
||||
@@ -152,6 +152,10 @@ export class Renderer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^check:/.test(topicId)) {
|
||||
topicId = topicId.replace('check:', '');
|
||||
}
|
||||
|
||||
pageProgressMessage.set('Updating progress');
|
||||
updateResourceProgress(
|
||||
{
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useToast } from '../../hooks/use-toast';
|
||||
import { TrashIcon } from '../ReactIcons/TrashIcon';
|
||||
import { AddedUserIcon } from '../ReactIcons/AddedUserIcon';
|
||||
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
|
||||
|
||||
type FriendProgressItemProps = {
|
||||
friend: ListFriendsResponse[0];
|
||||
onShowResourceProgress: (
|
||||
resourceId: string,
|
||||
isCustomResource?: boolean
|
||||
isCustomResource?: boolean,
|
||||
renderer?: AllowedRoadmapRenderer,
|
||||
) => void;
|
||||
onReload: () => void;
|
||||
};
|
||||
@@ -27,7 +29,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
const { response, error } = await httpDelete(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
|
||||
{}
|
||||
{},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -43,7 +45,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
|
||||
{}
|
||||
{},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -92,7 +94,8 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
onClick={() =>
|
||||
onShowResourceProgress(
|
||||
progress.resourceId,
|
||||
progress.isCustomResource
|
||||
progress.isCustomResource,
|
||||
progress?.renderer,
|
||||
)
|
||||
}
|
||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
@@ -160,7 +163,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
deleteFriend(friend.userId, 'Friend removed').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -198,7 +201,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
addFriend(friend.userId, 'Friend request accepted').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -225,7 +228,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
deleteFriend(friend.userId, 'Friend request removed').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -267,7 +270,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
onClick={() => {
|
||||
deleteFriend(
|
||||
friend.userId,
|
||||
'Friend request withdrawn'
|
||||
'Friend request withdrawn',
|
||||
).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
@@ -304,7 +307,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
addFriend(friend.userId, 'Friend request accepted').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="mb-1 block w-full max-w-[150px] rounded-md bg-black py-1.5 text-sm text-white"
|
||||
@@ -316,7 +319,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
onClick={() => {
|
||||
deleteFriend(
|
||||
friend.userId,
|
||||
'Friend request rejected'
|
||||
'Friend request rejected',
|
||||
).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { UserProgressModal } from '../UserProgress/UserProgressModal';
|
||||
import { InviteFriendPopup } from './InviteFriendPopup';
|
||||
import { UserCustomProgressModal } from '../UserProgress/UserCustomProgressModal';
|
||||
import { UserIcon } from 'lucide-react';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
|
||||
|
||||
type FriendResourceProgress = {
|
||||
updatedAt: string;
|
||||
@@ -22,6 +23,7 @@ type FriendResourceProgress = {
|
||||
skipped: number;
|
||||
done: number;
|
||||
total: number;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
};
|
||||
|
||||
export type ListFriendsResponse = {
|
||||
@@ -55,6 +57,7 @@ export function FriendsPage() {
|
||||
resourceId: string;
|
||||
friend: ListFriendsResponse[0];
|
||||
isCustomResource?: boolean;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
}>();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -92,8 +95,8 @@ export function FriendsPage() {
|
||||
(grouping) => grouping.value === selectedGrouping,
|
||||
);
|
||||
|
||||
const filteredFriends = friends.filter(
|
||||
(friend) => selectedGroupingType?.statuses.includes(friend.status),
|
||||
const filteredFriends = friends.filter((friend) =>
|
||||
selectedGroupingType?.statuses.includes(friend.status),
|
||||
);
|
||||
|
||||
const receivedRequests = friends.filter(
|
||||
@@ -124,6 +127,7 @@ export function FriendsPage() {
|
||||
resourceType={'roadmap'}
|
||||
onClose={() => setShowFriendProgress(undefined)}
|
||||
isCustomResource={showFriendProgress?.isCustomResource}
|
||||
renderer={showFriendProgress?.renderer}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -182,11 +186,16 @@ export function FriendsPage() {
|
||||
{filteredFriends.map((friend) => (
|
||||
<FriendProgressItem
|
||||
friend={friend}
|
||||
onShowResourceProgress={(resourceId, isCustomResource) => {
|
||||
onShowResourceProgress={(
|
||||
resourceId,
|
||||
isCustomResource,
|
||||
renderer,
|
||||
) => {
|
||||
setShowFriendProgress({
|
||||
resourceId,
|
||||
friend,
|
||||
isCustomResource,
|
||||
renderer,
|
||||
});
|
||||
}}
|
||||
key={friend.userId}
|
||||
|
||||
@@ -25,20 +25,14 @@ import { Ban, Cog, Download, PenSquare, Save, Wand } from 'lucide-react';
|
||||
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
|
||||
import { httpGet, httpPost } from '../../lib/http.ts';
|
||||
import { pageProgressMessage } from '../../stores/page.ts';
|
||||
import {
|
||||
deleteUrlParam,
|
||||
getUrlParams,
|
||||
setUrlParams,
|
||||
} from '../../lib/browser.ts';
|
||||
import { deleteUrlParam, getUrlParams } from '../../lib/browser.ts';
|
||||
import { downloadGeneratedRoadmapImage } from '../../helper/download-image.ts';
|
||||
import { showLoginPopup } from '../../lib/popup.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
|
||||
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
|
||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||
import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts';
|
||||
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
|
||||
import { useParams } from '../../hooks/use-params.ts';
|
||||
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
|
||||
import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx';
|
||||
|
||||
@@ -294,7 +288,10 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
||||
|
||||
setIsLoading(false);
|
||||
pageProgressMessage.set('');
|
||||
return response.roadmapSlug;
|
||||
return {
|
||||
roadmapId: response.roadmapId,
|
||||
roadmapSlug: response.roadmapSlug,
|
||||
};
|
||||
};
|
||||
|
||||
const downloadGeneratedRoadmapContent = async () => {
|
||||
@@ -686,9 +683,9 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:text-sm"
|
||||
onClick={async () => {
|
||||
const roadmapSlug = await saveAIRoadmap();
|
||||
if (roadmapSlug) {
|
||||
window.location.href = `/r/${roadmapSlug}`;
|
||||
const response = await saveAIRoadmap();
|
||||
if (response?.roadmapSlug) {
|
||||
window.location.href = `/r/${response.roadmapSlug}`;
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
@@ -703,10 +700,10 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
||||
<button
|
||||
className="hidden items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:inline-flex sm:text-sm"
|
||||
onClick={async () => {
|
||||
const roadmapId = await saveAIRoadmap();
|
||||
if (roadmapId) {
|
||||
const response = await saveAIRoadmap();
|
||||
if (response?.roadmapId) {
|
||||
window.open(
|
||||
`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`,
|
||||
`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${response?.roadmapId}`,
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
||||
setIsAuthenticatedUser(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
|
||||
const randomTerms = ['OAuth', 'UI / UX', 'SRE', 'DevRel'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow flex-col items-center px-4 py-6 sm:px-6 md:my-24 lg:my-32">
|
||||
|
||||
@@ -124,7 +124,7 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
|
||||
const openAIKey = getOpenAIKey();
|
||||
|
||||
return (
|
||||
<div className={'relative z-50'}>
|
||||
<div className={'relative z-[90]'}>
|
||||
<div
|
||||
ref={topicRef}
|
||||
tabIndex={0}
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
---
|
||||
import type { GuideFileType } from '../lib/guide';
|
||||
import type { GuideFileType, GuideFrontmatter } from '../lib/guide';
|
||||
import { replaceVariables } from '../lib/markdown';
|
||||
import { QuestionGroupType } from '../lib/question-group';
|
||||
|
||||
export interface Props {
|
||||
guide: GuideFileType;
|
||||
guide: GuideFileType | QuestionGroupType;
|
||||
}
|
||||
|
||||
function isQuestionGroupType(
|
||||
guide: GuideFileType | QuestionGroupType,
|
||||
): guide is QuestionGroupType {
|
||||
return (guide as QuestionGroupType).questions !== undefined;
|
||||
}
|
||||
|
||||
const { guide } = Astro.props;
|
||||
const { frontmatter, id } = guide;
|
||||
|
||||
let pageUrl = '';
|
||||
let guideType = '';
|
||||
|
||||
if (isQuestionGroupType(guide)) {
|
||||
pageUrl = `/questions/${id}`;
|
||||
guideType = 'Questions';
|
||||
} else {
|
||||
const excludedBySlug = (frontmatter as GuideFrontmatter).excludedBySlug;
|
||||
pageUrl = excludedBySlug ? excludedBySlug : `/guides/${id}`;
|
||||
guideType = (frontmatter as GuideFrontmatter).type;
|
||||
}
|
||||
---
|
||||
|
||||
<a
|
||||
class:list={[
|
||||
'text-md group block flex items-center justify-between border-b py-2 text-gray-600 no-underline hover:text-blue-600',
|
||||
]}
|
||||
href={frontmatter.excludedBySlug
|
||||
? frontmatter.excludedBySlug
|
||||
: `/guides/${id}`}
|
||||
href={pageUrl}
|
||||
>
|
||||
<span
|
||||
class='text-sm transition-transform group-hover:translate-x-2 md:text-base'
|
||||
@@ -38,7 +55,7 @@ const { frontmatter, id } = guide;
|
||||
}
|
||||
</span>
|
||||
<span class='hidden text-xs capitalize text-gray-500 sm:block'>
|
||||
{frontmatter.type}
|
||||
{guideType}
|
||||
</span>
|
||||
|
||||
<span class='block text-xs text-gray-400 sm:hidden'> »</span>
|
||||
|
||||
@@ -10,12 +10,12 @@ import { AIAnnouncement } from "../AIAnnouncement";
|
||||
class='container px-5 py-6 pb-14 text-left transition-opacity duration-300 sm:px-0 sm:py-20 sm:text-center'
|
||||
id='hero-text'
|
||||
>
|
||||
<p class='-mt-4 mb-7 sm:-mt-10'>
|
||||
<p class='-mt-4 mb-7 sm:-mt-10 sm:mb-4'>
|
||||
<AIAnnouncement />
|
||||
</p>
|
||||
|
||||
<h1
|
||||
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl'
|
||||
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl sm:leading-tight'
|
||||
>
|
||||
Developer Roadmaps
|
||||
</h1>
|
||||
|
||||
@@ -1,68 +1,173 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, User } from 'lucide-react';
|
||||
import { getUser, isLoggedIn } from '../../lib/jwt';
|
||||
import { AccountDropdownList } from './AccountDropdownList';
|
||||
import { DropdownTeamList } from './DropdownTeamList';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
import { OnboardingModal } from './OnboardingModal.tsx';
|
||||
import { httpGet } from '../../lib/http.ts';
|
||||
import { useToast } from '../../hooks/use-toast.ts';
|
||||
import type { UserDocument } from '../../api/user.ts';
|
||||
import { NotificationIndicator } from './NotificationIndicator.tsx';
|
||||
import { OnboardingNudge } from '../OnboardingNudge.tsx';
|
||||
|
||||
export type OnboardingConfig = Pick<
|
||||
UserDocument,
|
||||
'onboarding' | 'onboardingStatus'
|
||||
>;
|
||||
|
||||
export function AccountDropdown() {
|
||||
const toast = useToast();
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
const [isConfigLoading, setIsConfigLoading] = useState(false);
|
||||
const [isOnboardingModalOpen, setIsOnboardingModalOpen] = useState(false);
|
||||
const [onboardingConfig, setOnboardingConfig] = useState<
|
||||
OnboardingConfig | undefined
|
||||
>(undefined);
|
||||
const currentUser = getUser();
|
||||
|
||||
const shouldShowOnboardingStatus =
|
||||
currentUser?.onboardingStatus === 'pending' ||
|
||||
onboardingConfig?.onboardingStatus === 'pending';
|
||||
|
||||
const loadOnboardingConfig = async () => {
|
||||
if (!isLoggedIn() || !shouldShowOnboardingStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfigLoading(true);
|
||||
const { response, error } = await httpGet<OnboardingConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-onboarding-config`,
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to load onboarding config');
|
||||
}
|
||||
|
||||
setOnboardingConfig(response);
|
||||
};
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setShowDropdown(false);
|
||||
setIsTeamsOpen(false);
|
||||
setIsConfigLoading(true);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn() || !showDropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadOnboardingConfig().finally(() => {
|
||||
setIsConfigLoading(false);
|
||||
});
|
||||
}, [showDropdown]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = () => {
|
||||
loadOnboardingConfig().finally(() => {
|
||||
setIsConfigLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('visibilitychange', loadConfig);
|
||||
return () => {
|
||||
window.removeEventListener('visibilitychange', loadConfig);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onboardingDoneCount = Object.values(
|
||||
onboardingConfig?.onboarding || {},
|
||||
).filter((status) => status !== 'pending').length;
|
||||
const onboardingCount = Object.keys(
|
||||
onboardingConfig?.onboarding || {},
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="relative z-50 animate-fade-in">
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
<>
|
||||
{shouldShowOnboardingStatus && !isOnboardingModalOpen && (
|
||||
<OnboardingNudge
|
||||
onStartOnboarding={() => {
|
||||
loadOnboardingConfig().then(() => {
|
||||
setIsOnboardingModalOpen(true);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
|
||||
onClick={() => {
|
||||
setIsTeamsOpen(false);
|
||||
setShowDropdown(!showDropdown);
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
Account <span className="text-gray-300">/</span> Teams
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
|
||||
</button>
|
||||
<div className="relative z-[90] animate-fade-in">
|
||||
{isOnboardingModalOpen && onboardingConfig && (
|
||||
<OnboardingModal
|
||||
onboardingConfig={onboardingConfig}
|
||||
onClose={() => {
|
||||
setIsOnboardingModalOpen(false);
|
||||
}}
|
||||
onIgnoreTask={(taskId, status) => {
|
||||
loadOnboardingConfig().finally(() => {});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
|
||||
<button
|
||||
className="relative flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
|
||||
onClick={() => {
|
||||
setIsTeamsOpen(false);
|
||||
setShowDropdown(!showDropdown);
|
||||
}}
|
||||
>
|
||||
{isTeamsOpen ? (
|
||||
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
|
||||
) : (
|
||||
<AccountDropdownList
|
||||
onCreateRoadmap={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
setIsTeamsOpen={setIsTeamsOpen}
|
||||
/>
|
||||
<span className="inline-flex items-center">
|
||||
Account <span className="text-gray-300">/</span> Teams
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
|
||||
{shouldShowOnboardingStatus && !showDropdown && (
|
||||
<NotificationIndicator />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
|
||||
>
|
||||
{isTeamsOpen ? (
|
||||
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
|
||||
) : (
|
||||
<AccountDropdownList
|
||||
onCreateRoadmap={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
setIsTeamsOpen={setIsTeamsOpen}
|
||||
onOnboardingClick={() => {
|
||||
setIsOnboardingModalOpen(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
shouldShowOnboardingStatus={shouldShowOnboardingStatus}
|
||||
isConfigLoading={isConfigLoading}
|
||||
onboardingConfigCount={onboardingCount}
|
||||
doneConfigCount={onboardingDoneCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,21 +6,67 @@ import {
|
||||
SquareUserRound,
|
||||
User2,
|
||||
Users2,
|
||||
Handshake,
|
||||
} from 'lucide-react';
|
||||
import { logout } from './navigation';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { NotificationIndicator } from './NotificationIndicator.tsx';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon.tsx';
|
||||
|
||||
type AccountDropdownListProps = {
|
||||
onCreateRoadmap: () => void;
|
||||
setIsTeamsOpen: (isOpen: boolean) => void;
|
||||
onOnboardingClick: () => void;
|
||||
isConfigLoading: boolean;
|
||||
shouldShowOnboardingStatus?: boolean;
|
||||
onboardingConfigCount: number;
|
||||
doneConfigCount: number;
|
||||
};
|
||||
|
||||
export function AccountDropdownList(props: AccountDropdownListProps) {
|
||||
const { setIsTeamsOpen, onCreateRoadmap } = props;
|
||||
const {
|
||||
setIsTeamsOpen,
|
||||
onCreateRoadmap,
|
||||
onOnboardingClick,
|
||||
isConfigLoading = true,
|
||||
shouldShowOnboardingStatus = false,
|
||||
onboardingConfigCount,
|
||||
doneConfigCount,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{shouldShowOnboardingStatus && (
|
||||
<li className="mb-1 px-1">
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center rounded py-1 pl-3 pr-2 text-sm font-medium text-slate-100 hover:opacity-80',
|
||||
isConfigLoading
|
||||
? 'striped-loader-darker flex border-slate-800 opacity-70'
|
||||
: 'border-slate-600 bg-slate-700',
|
||||
)}
|
||||
onClick={onOnboardingClick}
|
||||
disabled={isConfigLoading}
|
||||
>
|
||||
<NotificationIndicator className="-left-0.5 -top-0.5" />
|
||||
|
||||
{isConfigLoading ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
<Handshake className="mr-2 h-4 w-4 text-slate-400 group-hover:text-white" />
|
||||
<span>Onboarding</span>
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs text-slate-400">
|
||||
{doneConfigCount} of {onboardingConfigCount}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
<li className="px-1">
|
||||
<a
|
||||
href="/account"
|
||||
|
||||
@@ -17,10 +17,10 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
</a>
|
||||
|
||||
<a
|
||||
href='/ai'
|
||||
href='/teams'
|
||||
class='group inline sm:hidden relative !mr-2 text-blue-300 hover:text-white'
|
||||
>
|
||||
AI Roadmaps
|
||||
Teams
|
||||
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
@@ -39,13 +39,11 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
<a href='/get-started' class='text-gray-400 hover:text-white'>
|
||||
Start Here
|
||||
</a>
|
||||
<a href='/teams' class='text-gray-400 hover:text-white'> Teams</a>
|
||||
<a
|
||||
href='/ai'
|
||||
<a
|
||||
href='/teams'
|
||||
class='group relative !mr-2 text-blue-300 hover:text-white'
|
||||
>
|
||||
AI Roadmaps
|
||||
|
||||
Teams
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
@@ -56,6 +54,8 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href='/ai' class='text-gray-400 hover:text-white'> AI Roadmaps</a>
|
||||
<button
|
||||
data-command-menu
|
||||
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1.5 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 md:flex'
|
||||
|
||||
20
src/components/Navigation/NotificationIndicator.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
type NotificationIndicatorProps = {
|
||||
className?: string;
|
||||
};
|
||||
export function NotificationIndicator(props: NotificationIndicatorProps) {
|
||||
const { className = '' } = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1 right-0 h-3 w-3 text-xs uppercase tracking-wider',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
253
src/components/Navigation/OnboardingModal.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { ArrowUpRight, Check } from 'lucide-react';
|
||||
import { Modal } from '../Modal';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { AllowedOnboardingStatus } from '../../api/user';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { httpPatch } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { OnboardingConfig } from './AccountDropdown';
|
||||
import { setAuthToken } from '../../lib/jwt';
|
||||
import { NUDGE_ONBOARDING_KEY } from '../OnboardingNudge.tsx';
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: AllowedOnboardingStatus;
|
||||
url: string;
|
||||
urlText: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type OnboardingModalProps = {
|
||||
onClose: () => void;
|
||||
onboardingConfig: OnboardingConfig;
|
||||
onIgnoreTask?: (taskId: string, status: AllowedOnboardingStatus) => void;
|
||||
};
|
||||
|
||||
export function OnboardingModal(props: OnboardingModalProps) {
|
||||
const { onboardingConfig, onClose, onIgnoreTask } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'updateProgress',
|
||||
title: 'Update your Progress',
|
||||
description: 'Mark your progress on roadmaps',
|
||||
status: onboardingConfig?.onboarding?.updateProgress || 'pending',
|
||||
url: '/roadmaps',
|
||||
urlText: 'Roadmaps List',
|
||||
},
|
||||
{
|
||||
id: 'publishProfile',
|
||||
title: 'Claim a Username',
|
||||
description: 'Optionally create a public profile to share your skills',
|
||||
status: onboardingConfig?.onboarding?.publishProfile || 'pending',
|
||||
url: '/account/update-profile',
|
||||
urlText: 'Update Profile',
|
||||
},
|
||||
{
|
||||
id: 'customRoadmap',
|
||||
title: 'Custom Roadmaps',
|
||||
description: 'Create your own roadmap from scratch',
|
||||
status: onboardingConfig?.onboarding?.customRoadmap || 'pending',
|
||||
url: import.meta.env.DEV
|
||||
? 'http://localhost:4321'
|
||||
: 'https://draw.roadmap.sh',
|
||||
urlText: 'Create Roadmap',
|
||||
},
|
||||
{
|
||||
id: 'addFriends',
|
||||
title: 'Invite your Friends',
|
||||
description: 'Invite friends to join you on roadmaps',
|
||||
status: onboardingConfig?.onboarding?.addFriends || 'pending',
|
||||
url: '/account/friends',
|
||||
urlText: 'Add Friends',
|
||||
onClick: () => {
|
||||
ignoreOnboardingTask(
|
||||
'addFriends',
|
||||
'done',
|
||||
'Updating status..',
|
||||
).finally(() => pageProgressMessage.set(''));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'roadCard',
|
||||
title: 'Create your Roadmap Card',
|
||||
description: 'Embed your skill card on your github or website',
|
||||
status: onboardingConfig?.onboarding?.roadCard || 'pending',
|
||||
url: '/account/road-card',
|
||||
urlText: 'Create Road Card',
|
||||
onClick: () => {
|
||||
ignoreOnboardingTask('roadCard', 'done', 'Updating status..').finally(
|
||||
() => pageProgressMessage.set(''),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inviteTeam',
|
||||
title: 'Invite your Team',
|
||||
description: 'Invite your team to collaborate on roadmaps',
|
||||
status: onboardingConfig?.onboarding?.inviteTeam || 'pending',
|
||||
url: '/team/new',
|
||||
urlText: 'Create Team',
|
||||
},
|
||||
];
|
||||
}, [onboardingConfig]);
|
||||
|
||||
const ignoreOnboardingTask = async (
|
||||
taskId: string,
|
||||
status: AllowedOnboardingStatus,
|
||||
message: string = 'Ignoring Task',
|
||||
) => {
|
||||
pageProgressMessage.set(message);
|
||||
const { response, error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-onboarding-config`,
|
||||
{
|
||||
id: taskId,
|
||||
status,
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to ignore task');
|
||||
return;
|
||||
}
|
||||
|
||||
onIgnoreTask?.(taskId, status);
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const ignoreForever = async () => {
|
||||
const { response, error } = await httpPatch<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-ignore-onboarding-forever`,
|
||||
{},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to ignore onboarding');
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthToken(response.token);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const isAllTasksDone = tasks.every(
|
||||
(task) => task.status === 'done' || task.status === 'ignored',
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isAllTasksDone) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('Finishing Onboarding');
|
||||
ignoreForever().finally(() => {});
|
||||
}, [isAllTasksDone]);
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} bodyClassName="text-black h-auto">
|
||||
<div className="px-4 pb-2 pl-11 pt-4">
|
||||
<h2 className="mb-0.5 text-xl font-semibold">Welcome to roadmap.sh</h2>
|
||||
<p className="text-balance text-sm text-gray-500">
|
||||
Complete the tasks below to get started!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
className={cn('flex flex-col divide-y', {
|
||||
'border-b': tasks[tasks.length - 1]?.status === 'done',
|
||||
})}
|
||||
>
|
||||
{/*sort to put completed tasks at the end */}
|
||||
{tasks.map((task, taskCounter) => {
|
||||
const isDone = task.status === 'done';
|
||||
const isActive = selectedTask?.id === task.id;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
data-active={isActive}
|
||||
data-status={task.status}
|
||||
className={cn('group/task px-4 py-2.5', {
|
||||
'bg-gray-100': isDone,
|
||||
'border-t': taskCounter === 0 && isDone,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('flex items-start gap-2', {
|
||||
'opacity-50': task.status === 'done',
|
||||
})}
|
||||
>
|
||||
<span className="relative top-px flex h-5 w-5 items-center justify-center">
|
||||
{isDone ? (
|
||||
<Check className="h-4 w-4 stroke-[3px] text-green-500" />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'h-4 w-4 rounded-md border border-gray-300',
|
||||
task.status === 'ignored'
|
||||
? 'bg-gray-200'
|
||||
: 'bg-transparent',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="group-data-[status=ignored]/task:text-gray-400">
|
||||
<h3 className="flex items-center text-sm font-semibold group-data-[status=done]/task:line-through">
|
||||
{task.title}
|
||||
|
||||
<a
|
||||
href={task.url}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
'ml-1 inline-block rounded-xl border border-black bg-white pl-1.5 pr-1 text-xs font-normal text-black hover:bg-black hover:text-white',
|
||||
)}
|
||||
aria-label="Open task in new tab"
|
||||
onClick={() => {
|
||||
if (!task?.onClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.onClick();
|
||||
}}
|
||||
>
|
||||
{task.urlText}
|
||||
<ArrowUpRight className="relative -top-[0.5px] ml-0.5 inline-block h-3.5 w-3.5 stroke-[2px]" />
|
||||
</a>
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 group-data-[status=ignored]/task:text-gray-400">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="mt-2 px-11 pb-5">
|
||||
<button
|
||||
className="w-full rounded-md bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
|
||||
onClick={onClose}
|
||||
>
|
||||
Do it later
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="mt-3 text-sm text-gray-500 underline underline-offset-2 hover:text-black"
|
||||
onClick={() => {
|
||||
pageProgressMessage.set('Ignoring Onboarding');
|
||||
ignoreForever().finally();
|
||||
}}
|
||||
>
|
||||
Ignore forever
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
69
src/components/OnboardingNudge.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { cn } from '../lib/classname.ts';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useScrollPosition } from '../hooks/use-scroll-position.ts';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type OnboardingNudgeProps = {
|
||||
onStartOnboarding: () => void;
|
||||
};
|
||||
|
||||
export const NUDGE_ONBOARDING_KEY = 'should_nudge_onboarding';
|
||||
|
||||
export function OnboardingNudge(props: OnboardingNudgeProps) {
|
||||
const { onStartOnboarding } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { y: scrollY } = useScrollPosition();
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(NUDGE_ONBOARDING_KEY) === null) {
|
||||
localStorage.setItem(NUDGE_ONBOARDING_KEY, 'true');
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (localStorage.getItem(NUDGE_ONBOARDING_KEY) !== 'true') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scrollY < 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed left-0 right-0 top-0 z-[91] flex w-full items-center justify-center bg-yellow-300 border-b border-b-yellow-500/30 pt-1.5 pb-2',
|
||||
{
|
||||
'striped-loader': isLoading,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<p className="text-base font-semibold text-yellow-950">
|
||||
Welcome! Please take a moment to{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsLoading(true);
|
||||
localStorage.setItem(NUDGE_ONBOARDING_KEY, 'false');
|
||||
onStartOnboarding();
|
||||
}}
|
||||
className="underline"
|
||||
>
|
||||
complete onboarding
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="relative top-[3px] ml-1 px-1 py-1 text-yellow-600 hover:text-yellow-950"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
localStorage.setItem(NUDGE_ONBOARDING_KEY, 'false');
|
||||
setIsLoading(true);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" strokeWidth={3} />
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const discordInfo = await getDiscordInfo();
|
||||
class='mt-5 grid grid-cols-1 justify-between gap-2 divide-x-0 sm:my-11 sm:grid-cols-3 sm:gap-0 sm:divide-x mb-4 sm:mb-0'
|
||||
>
|
||||
<OpenSourceStat text='GitHub Stars' value={starCount} />
|
||||
<OpenSourceStat text='Registered Users' value={'850k'} />
|
||||
<OpenSourceStat text='Registered Users' value={'+1M'} />
|
||||
<OpenSourceStat
|
||||
text='Discord Members'
|
||||
value={discordInfo.totalFormatted}
|
||||
|
||||
@@ -28,7 +28,7 @@ const isDiscordMembers = text.toLowerCase() === 'discord members';
|
||||
{
|
||||
isRegistered && (
|
||||
<p class='flex items-center text-sm text-blue-500 sm:flex'>
|
||||
<span class='mr-1.5 rounded-md bg-blue-500 px-1 text-white'>+55k</span>
|
||||
<span class='mr-1.5 rounded-md bg-blue-500 px-1 text-white'>+75k</span>
|
||||
every month
|
||||
</p>
|
||||
)
|
||||
@@ -44,7 +44,7 @@ const isDiscordMembers = text.toLowerCase() === 'discord members';
|
||||
}
|
||||
<div class="flex flex-row items-center sm:flex-col my-1 sm:my-0">
|
||||
<p
|
||||
class='relative my-0 sm:my-4 mr-1 sm:mr-0 text-base font-bold lowercase sm:w-auto sm:text-5xl'
|
||||
class='relative my-0 sm:my-4 mr-1 sm:mr-0 text-base font-bold sm:w-auto sm:text-5xl'
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function PageProgress(props: Props) {
|
||||
return (
|
||||
<div>
|
||||
{/* Tailwind based spinner for full page */}
|
||||
<div className="fixed left-0 top-0 z-50 flex h-full w-full items-center justify-center bg-white bg-opacity-75">
|
||||
<div className="fixed left-0 top-0 z-[100] flex h-full w-full items-center justify-center bg-white bg-opacity-75">
|
||||
<div className="flex items-center justify-center rounded-md border bg-white px-4 py-2 ">
|
||||
<Spinner
|
||||
className="h-4 w-4 sm:h-4 sm:w-4"
|
||||
|
||||
@@ -4,6 +4,8 @@ import { sponsorHidden } from '../stores/page';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { X } from 'lucide-react';
|
||||
import { setViewSponsorCookie } from '../lib/jwt';
|
||||
import { isMobile } from '../lib/is-mobile';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
export type PageSponsorType = {
|
||||
company: string;
|
||||
@@ -25,6 +27,22 @@ type PageSponsorProps = {
|
||||
gaPageIdentifier?: string;
|
||||
};
|
||||
|
||||
const CLOSE_SPONSOR_KEY = 'sponsorClosed';
|
||||
|
||||
function markSponsorHidden(sponsorId: string) {
|
||||
Cookies.set(`${CLOSE_SPONSOR_KEY}-${sponsorId}`, '1', {
|
||||
path: '/',
|
||||
expires: 1,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
}
|
||||
|
||||
function isSponsorMarkedHidden(sponsorId: string) {
|
||||
return Cookies.get(`${CLOSE_SPONSOR_KEY}-${sponsorId}`) === '1';
|
||||
}
|
||||
|
||||
export function PageSponsor(props: PageSponsorProps) {
|
||||
const { gaPageIdentifier } = props;
|
||||
const $isSponsorHidden = useStore(sponsorHidden);
|
||||
@@ -50,6 +68,7 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
|
||||
{
|
||||
href: window.location.pathname,
|
||||
mobile: isMobile() ? 'true' : 'false',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -58,12 +77,16 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response?.sponsor) {
|
||||
if (
|
||||
!response?.sponsor ||
|
||||
!response.id ||
|
||||
isSponsorMarkedHidden(response.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSponsor(response.sponsor);
|
||||
setSponsorId(response?.id || null);
|
||||
setSponsorId(response.id);
|
||||
|
||||
window.fireEvent({
|
||||
category: 'SponsorImpression',
|
||||
@@ -75,9 +98,15 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
};
|
||||
|
||||
const clickSponsor = async (sponsorId: string) => {
|
||||
const { response, error } = await httpPatch<{ status: 'ok' }>(
|
||||
const clickUrl = new URL(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-view-sponsor/${sponsorId}`,
|
||||
{},
|
||||
);
|
||||
|
||||
const { response, error } = await httpPatch<{ status: 'ok' }>(
|
||||
clickUrl.toString(),
|
||||
{
|
||||
mobile: isMobile(),
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -103,7 +132,7 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener sponsored nofollow"
|
||||
className="fixed bottom-[15px] right-[15px] z-50 flex max-w-[350px] bg-white shadow-lg outline-0 outline-transparent"
|
||||
className="fixed bottom-0 left-0 right-0 z-50 flex bg-white shadow-lg outline-0 outline-transparent sm:bottom-[15px] sm:left-auto sm:right-[15px] sm:max-w-[350px]"
|
||||
onClick={async () => {
|
||||
window.fireEvent({
|
||||
category: 'SponsorClick',
|
||||
@@ -114,26 +143,32 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute right-1.5 top-1.5 text-gray-300 hover:text-gray-800"
|
||||
className="absolute right-1 top-1 text-gray-400 hover:text-gray-800 sm:right-1.5 sm:top-1.5 sm:text-gray-300"
|
||||
aria-label="Close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
markSponsorHidden(sponsorId || '');
|
||||
sponsorHidden.set(true);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-5 w-5 sm:h-4 sm:w-4" />
|
||||
</span>
|
||||
<img
|
||||
src={imageUrl}
|
||||
className="block h-[150px] object-cover lg:h-[169px] lg:w-[118.18px]"
|
||||
alt="Sponsor Banner"
|
||||
/>
|
||||
<span className="flex flex-1 flex-col justify-between text-sm">
|
||||
<span>
|
||||
<img
|
||||
src={imageUrl}
|
||||
className="block h-[106px] object-cover sm:h-[169px] sm:w-[118.18px]"
|
||||
alt="Sponsor Banner"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex flex-1 flex-col justify-between text-xs sm:text-sm">
|
||||
<span className="p-[10px]">
|
||||
<span className="mb-0.5 block font-semibold">{title}</span>
|
||||
<span className="block text-gray-500">{description}</span>
|
||||
</span>
|
||||
<span className="sponsor-footer">Partner Content</span>
|
||||
<span className="sponsor-footer hidden sm:block">Partner Content</span>
|
||||
<span className="block pb-1 text-center text-[10px] uppercase text-gray-400 sm:hidden">
|
||||
Partner Content
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -13,28 +13,19 @@ type ProgressStatButtonProps = {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function ProgressStatButton(props: ProgressStatButtonProps) {
|
||||
const { icon, label, count, onClick, isDisabled = false } = props;
|
||||
function ProgressStatLabel(props: ProgressStatButtonProps) {
|
||||
const { icon, label, count } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
className="group relative flex flex-1 items-center overflow-hidden rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors hover:border-black disabled:pointer-events-none disabled:opacity-50 sm:rounded-xl sm:px-4 sm:py-3 sm:text-base"
|
||||
>
|
||||
<span className="group relative flex flex-1 items-center overflow-hidden rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors disabled:opacity-50 sm:rounded-xl sm:px-4 sm:py-3 sm:text-base">
|
||||
{icon}
|
||||
<span className="flex flex-grow justify-between">
|
||||
<span>{label}</span>
|
||||
<span>{count}</span>
|
||||
</span>
|
||||
|
||||
<span className="absolute left-0 right-0 top-full flex h-full items-center justify-center border border-black bg-black text-white transition-all duration-200 group-hover:top-0">
|
||||
Restart Asking
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,12 +34,11 @@ type QuestionFinishedProps = {
|
||||
didNotKnowCount: number;
|
||||
skippedCount: number;
|
||||
totalCount: number;
|
||||
onReset: (type: QuestionProgressType | 'reset') => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
export function QuestionFinished(props: QuestionFinishedProps) {
|
||||
const { knowCount, didNotKnowCount, skippedCount, totalCount, onReset } =
|
||||
props;
|
||||
const { knowCount, didNotKnowCount, skippedCount, onReset } = props;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-grow flex-col items-center justify-center px-4 sm:px-0">
|
||||
@@ -63,31 +53,25 @@ export function QuestionFinished(props: QuestionFinishedProps) {
|
||||
</p>
|
||||
|
||||
<div className="mb-5 mt-5 flex w-full flex-col gap-1.5 px-2 sm:flex-row sm:gap-3 sm:px-16">
|
||||
<ProgressStatButton
|
||||
<ProgressStatLabel
|
||||
icon={<ThumbsUp className="mr-1 h-4" />}
|
||||
label="Knew"
|
||||
count={knowCount}
|
||||
isDisabled={knowCount === 0}
|
||||
onClick={() => onReset('know')}
|
||||
/>
|
||||
<ProgressStatButton
|
||||
<ProgressStatLabel
|
||||
icon={<Sparkles className="mr-1 h-4" />}
|
||||
label="Learned"
|
||||
count={didNotKnowCount}
|
||||
isDisabled={didNotKnowCount === 0}
|
||||
onClick={() => onReset('dontKnow')}
|
||||
/>
|
||||
<ProgressStatButton
|
||||
<ProgressStatLabel
|
||||
icon={<SkipForward className="mr-1 h-4" />}
|
||||
label="Skipped"
|
||||
count={skippedCount}
|
||||
isDisabled={skippedCount === 0}
|
||||
onClick={() => onReset('skip')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 mt-2 text-sm sm:mb-0">
|
||||
<button
|
||||
onClick={() => onReset('reset')}
|
||||
onClick={() => onReset()}
|
||||
className="flex items-center gap-0.5 text-sm text-red-700 hover:text-black sm:text-base"
|
||||
>
|
||||
<RefreshCcw className="mr-1 h-4" />
|
||||
|
||||
154
src/components/Questions/QuestionGuide.astro
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
import {
|
||||
getGuideTableOfContent,
|
||||
type GuideFileType,
|
||||
HeadingGroupType,
|
||||
} from '../../lib/guide';
|
||||
import MarkdownFile from '../MarkdownFile.astro';
|
||||
import { TableOfContent } from '../TableOfContent/TableOfContent';
|
||||
import { markdownToHtml, replaceVariables } from '../../lib/markdown';
|
||||
import { QuestionGroupType } from '../../lib/question-group';
|
||||
import { QuestionsList } from './QuestionsList';
|
||||
|
||||
interface Props {
|
||||
questionGroup: QuestionGroupType;
|
||||
}
|
||||
|
||||
const { questionGroup } = Astro.props;
|
||||
|
||||
const allHeadings = questionGroup.getHeadings();
|
||||
const tableOfContent: HeadingGroupType[] = [
|
||||
...getGuideTableOfContent(allHeadings),
|
||||
{
|
||||
depth: 2,
|
||||
title: 'Test with Flashcards',
|
||||
children: [],
|
||||
slug: 'test-with-flashcards',
|
||||
text: 'Test yourself with Flashcards',
|
||||
},
|
||||
{
|
||||
depth: 2,
|
||||
title: 'Questions List',
|
||||
children: [
|
||||
{
|
||||
depth: 2,
|
||||
title: 'Beginner Level',
|
||||
children: [],
|
||||
slug: 'beginner-level',
|
||||
text: 'Beginner Level',
|
||||
} as HeadingGroupType,
|
||||
{
|
||||
depth: 2,
|
||||
title: 'Intermediate Level',
|
||||
children: [],
|
||||
slug: 'intermediate-level',
|
||||
text: 'Intermediate Level',
|
||||
} as HeadingGroupType,
|
||||
{
|
||||
depth: 2,
|
||||
title: 'Advanced Level',
|
||||
children: [],
|
||||
slug: 'advanced-level',
|
||||
text: 'Advanced Level',
|
||||
} as HeadingGroupType,
|
||||
],
|
||||
slug: 'questions-list',
|
||||
text: 'Questions List',
|
||||
},
|
||||
];
|
||||
|
||||
const showTableOfContent = tableOfContent.length > 0;
|
||||
const { frontmatter: guideFrontmatter, author } = questionGroup;
|
||||
---
|
||||
|
||||
<article class='lg:grid lg:max-w-full lg:grid-cols-[1fr_minmax(0,700px)_1fr]'>
|
||||
{
|
||||
showTableOfContent && (
|
||||
<div class='bg-gradient-to-r from-gray-50 py-0 lg:col-start-3 lg:col-end-4 lg:row-start-1'>
|
||||
<TableOfContent toc={tableOfContent} client:load />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'col-start-2 col-end-3 row-start-1 mx-auto max-w-[700px] py-5 sm:py-10',
|
||||
{
|
||||
'lg:border-r': showTableOfContent,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MarkdownFile>
|
||||
<h1 class='mb-3 text-balance text-4xl font-bold'>
|
||||
{replaceVariables(guideFrontmatter.title)}
|
||||
</h1>
|
||||
{
|
||||
author && (
|
||||
<p class='my-0 flex items-center justify-start text-sm text-gray-400'>
|
||||
<a
|
||||
href={`/authors/${author?.id}`}
|
||||
class='inline-flex items-center font-medium underline-offset-2 hover:text-gray-600 hover:underline'
|
||||
>
|
||||
<img
|
||||
alt={author.frontmatter.name}
|
||||
src={author.frontmatter.imageUrl}
|
||||
class='mb-0 mr-2 inline h-5 w-5 rounded-full'
|
||||
/>
|
||||
{author.frontmatter.name}
|
||||
</a>
|
||||
<span class='mx-2 hidden sm:inline'>·</span>
|
||||
<a
|
||||
class='hidden underline-offset-2 hover:text-gray-600 sm:inline'
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/question-groups/${questionGroup.id}`}
|
||||
target='_blank'
|
||||
>
|
||||
Improve this Guide
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<questionGroup.Content />
|
||||
|
||||
<h2 id='test-with-flashcards'>Test yourself with Flashcards</h2>
|
||||
<p>
|
||||
You can either use these flashcards or jump to the questions list
|
||||
section below to see them in a list format.
|
||||
</p>
|
||||
<div class='mx-0 sm:-mb-32'>
|
||||
<QuestionsList
|
||||
groupId={questionGroup.id}
|
||||
questions={questionGroup.questions}
|
||||
client:load
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 id='questions-list'>Questions List</h2>
|
||||
<p>
|
||||
If you prefer to see the questions in a list format, you can find them
|
||||
below.
|
||||
</p>
|
||||
|
||||
{
|
||||
['beginner', 'intermediate', 'advanced'].map((questionLevel) => (
|
||||
<div class='mb-5'>
|
||||
<h3 id={`${questionLevel}-level`} class='mb-0 capitalize'>
|
||||
{questionLevel} Level
|
||||
</h3>
|
||||
{questionGroup.questions
|
||||
.filter((q) => {
|
||||
return q.topics
|
||||
.map((t) => t.toLowerCase())
|
||||
.includes(questionLevel);
|
||||
})
|
||||
.map((q) => (
|
||||
<div class='mb-5'>
|
||||
<h4>{q.question}</h4>
|
||||
<div set:html={markdownToHtml(q.answer, false)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</MarkdownFile>
|
||||
</div>
|
||||
</article>
|
||||
@@ -24,14 +24,14 @@ type QuestionsListProps = {
|
||||
};
|
||||
|
||||
export function QuestionsList(props: QuestionsListProps) {
|
||||
const { questions: unshuffledQuestions, groupId } = props;
|
||||
const { questions: defaultQuestions, groupId } = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [questions, setQuestions] = useState(defaultQuestions);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [questions, setQuestions] = useState<QuestionType[]>();
|
||||
const [pendingQuestions, setPendingQuestions] = useState<QuestionType[]>([]);
|
||||
const [currQuestionIndex, setCurrQuestionIndex] = useState(0);
|
||||
|
||||
const [userProgress, setUserProgress] = useState<UserQuestionProgress>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -57,7 +57,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
return response;
|
||||
}
|
||||
|
||||
async function loadQuestions() {
|
||||
async function prepareProgress() {
|
||||
const userProgress = await fetchUserProgress();
|
||||
setUserProgress(userProgress);
|
||||
|
||||
@@ -65,7 +65,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
const didNotKnowQuestions = userProgress?.dontKnow || [];
|
||||
const skipQuestions = userProgress?.skip || [];
|
||||
|
||||
const pendingQuestions = unshuffledQuestions.filter((question) => {
|
||||
const pendingQuestionIndex = questions.findIndex((question) => {
|
||||
return (
|
||||
!knownQuestions.includes(question.id) &&
|
||||
!didNotKnowQuestions.includes(question.id) &&
|
||||
@@ -73,30 +73,21 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
);
|
||||
});
|
||||
|
||||
// Shuffle and set pending questions
|
||||
setPendingQuestions(pendingQuestions.sort(() => Math.random() - 0.5));
|
||||
setQuestions(unshuffledQuestions);
|
||||
|
||||
setCurrQuestionIndex(pendingQuestionIndex);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function resetProgress(type: QuestionProgressType | 'reset' = 'reset') {
|
||||
async function resetProgress() {
|
||||
let knownQuestions = userProgress?.know || [];
|
||||
let didNotKnowQuestions = userProgress?.dontKnow || [];
|
||||
let skipQuestions = userProgress?.skip || [];
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
if (type === 'know') {
|
||||
knownQuestions = [];
|
||||
} else if (type === 'dontKnow') {
|
||||
didNotKnowQuestions = [];
|
||||
} else if (type === 'skip') {
|
||||
skipQuestions = [];
|
||||
} else if (type === 'reset') {
|
||||
knownQuestions = [];
|
||||
didNotKnowQuestions = [];
|
||||
skipQuestions = [];
|
||||
}
|
||||
setQuestions(defaultQuestions);
|
||||
|
||||
knownQuestions = [];
|
||||
didNotKnowQuestions = [];
|
||||
skipQuestions = [];
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -105,7 +96,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-reset-question-progress/${groupId}`,
|
||||
{
|
||||
status: type,
|
||||
status: 'reset',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -119,21 +110,13 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
skipQuestions = response?.skip || [];
|
||||
}
|
||||
|
||||
const pendingQuestions = unshuffledQuestions.filter((question) => {
|
||||
return (
|
||||
!knownQuestions.includes(question.id) &&
|
||||
!didNotKnowQuestions.includes(question.id) &&
|
||||
!skipQuestions.includes(question.id)
|
||||
);
|
||||
});
|
||||
|
||||
setCurrQuestionIndex(0);
|
||||
setUserProgress({
|
||||
know: knownQuestions,
|
||||
dontKnow: didNotKnowQuestions,
|
||||
skip: skipQuestions,
|
||||
});
|
||||
|
||||
setPendingQuestions(pendingQuestions.sort(() => Math.random() - 0.5));
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -172,30 +155,29 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
newProgress = response;
|
||||
}
|
||||
|
||||
const updatedQuestionList = pendingQuestions.filter(
|
||||
(q) => q.id !== questionId,
|
||||
);
|
||||
const nextQuestionIndex = currQuestionIndex + 1;
|
||||
|
||||
setUserProgress(newProgress);
|
||||
setPendingQuestions(updatedQuestionList);
|
||||
setIsLoading(false);
|
||||
|
||||
if (updatedQuestionList.length === 0) {
|
||||
if (!nextQuestionIndex || !questions[nextQuestionIndex]) {
|
||||
setShowConfetti(true);
|
||||
}
|
||||
|
||||
setCurrQuestionIndex(nextQuestionIndex);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions().then(() => null);
|
||||
}, [unshuffledQuestions]);
|
||||
prepareProgress().then(() => null);
|
||||
}, [questions]);
|
||||
|
||||
const knowCount = userProgress?.know.length || 0;
|
||||
const dontKnowCount = userProgress?.dontKnow.length || 0;
|
||||
const skipCount = userProgress?.skip.length || 0;
|
||||
const hasProgress = knowCount > 0 || dontKnowCount > 0 || skipCount > 0;
|
||||
|
||||
const currQuestion = pendingQuestions[0];
|
||||
const hasFinished = !isLoading && hasProgress && !currQuestion;
|
||||
const currQuestion = questions[currQuestionIndex];
|
||||
const hasFinished = !isLoading && hasProgress && currQuestionIndex === -1;
|
||||
|
||||
return (
|
||||
<div className="mb-0 gap-3 text-center sm:mb-40">
|
||||
@@ -203,11 +185,37 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
knowCount={knowCount}
|
||||
didNotKnowCount={dontKnowCount}
|
||||
skippedCount={skipCount}
|
||||
totalCount={unshuffledQuestions?.length || questions?.length}
|
||||
totalCount={questions?.length}
|
||||
isLoading={isLoading}
|
||||
showLoginAlert={!isLoggedIn() && hasProgress}
|
||||
onResetClick={() => {
|
||||
resetProgress('reset').finally(() => null);
|
||||
resetProgress().finally(() => null);
|
||||
}}
|
||||
onNextClick={() => {
|
||||
if (
|
||||
currQuestionIndex !== -1 &&
|
||||
currQuestionIndex < questions.length - 1
|
||||
) {
|
||||
updateQuestionStatus('skip', currQuestion.id).finally(() => null);
|
||||
}
|
||||
}}
|
||||
onPrevClick={() => {
|
||||
if (currQuestionIndex > 0) {
|
||||
const prevQuestion = questions[currQuestionIndex - 1];
|
||||
// remove last question from the progress of the user
|
||||
const tempUserProgress = {
|
||||
know:
|
||||
userProgress?.know.filter((id) => id !== prevQuestion.id) || [],
|
||||
dontKnow:
|
||||
userProgress?.dontKnow.filter((id) => id !== prevQuestion.id) ||
|
||||
[],
|
||||
skip:
|
||||
userProgress?.skip.filter((id) => id !== prevQuestion.id) || [],
|
||||
};
|
||||
|
||||
setUserProgress(tempUserProgress);
|
||||
setCurrQuestionIndex(currQuestionIndex - 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -227,12 +235,12 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
>
|
||||
{hasFinished && (
|
||||
<QuestionFinished
|
||||
totalCount={unshuffledQuestions?.length || questions?.length || 0}
|
||||
totalCount={questions?.length || 0}
|
||||
knowCount={knowCount}
|
||||
didNotKnowCount={dontKnowCount}
|
||||
skippedCount={skipCount}
|
||||
onReset={(type: QuestionProgressType | 'reset') => {
|
||||
resetProgress(type).finally(() => null);
|
||||
onReset={() => {
|
||||
resetProgress().finally(() => null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { CheckCircle, RotateCcw, SkipForward, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RotateCcw,
|
||||
SkipForward,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
type QuestionsProgressProps = {
|
||||
@@ -9,6 +16,8 @@ type QuestionsProgressProps = {
|
||||
totalCount?: number;
|
||||
skippedCount?: number;
|
||||
onResetClick?: () => void;
|
||||
onPrevClick?: () => void;
|
||||
onNextClick?: () => void;
|
||||
};
|
||||
|
||||
export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
@@ -20,6 +29,8 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
totalCount = 0,
|
||||
skippedCount = 0,
|
||||
onResetClick = () => null,
|
||||
onPrevClick = () => null,
|
||||
onNextClick = () => null,
|
||||
} = props;
|
||||
|
||||
const totalSolved = knowCount + didNotKnowCount + skippedCount;
|
||||
@@ -36,8 +47,22 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-3 text-sm">
|
||||
{totalSolved} / {totalCount}
|
||||
<span className="ml-3 flex items-center text-sm">
|
||||
<button
|
||||
onClick={onPrevClick}
|
||||
className="text-zinc-400 hover:text-black"
|
||||
>
|
||||
<ChevronLeft className="h-4" strokeWidth={3} />
|
||||
</button>
|
||||
<span className="block min-w-[41px] text-center">
|
||||
<span className="tabular-nums">{totalSolved}</span> / {totalCount}
|
||||
</span>
|
||||
<button
|
||||
onClick={onNextClick}
|
||||
className="text-zinc-400 hover:text-black"
|
||||
>
|
||||
<ChevronRight className="h-4" strokeWidth={3} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -46,9 +71,7 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
<CheckCircle className="mr-1 h-4" />
|
||||
<span>Knew</span>
|
||||
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
|
||||
<span className="tabular-nums">{knowCount}</span>{' '}
|
||||
<span className="hidden lg:inline">Questions</span>
|
||||
<span className="inline sm:hidden">Questions</span>
|
||||
<span className="tabular-nums">{knowCount}</span> Items
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -56,9 +79,7 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
<Sparkles className="mr-1 h-4" />
|
||||
<span>Learnt</span>
|
||||
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
|
||||
<span className="tabular-nums">{didNotKnowCount}</span>{' '}
|
||||
<span className="hidden lg:inline">Questions</span>
|
||||
<span className="inline sm:hidden">Questions</span>
|
||||
<span className="tabular-nums">{didNotKnowCount}</span> Items
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -66,9 +87,7 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
<SkipForward className="mr-1 h-4" />
|
||||
<span>Skipped</span>
|
||||
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
|
||||
<span className="tabular-nums">{skippedCount}</span>{' '}
|
||||
<span className="hidden lg:inline">Questions</span>
|
||||
<span className="inline sm:hidden">Questions</span>
|
||||
<span className="tabular-nums">{skippedCount}</span> Items
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { getQuestionGroupsByIds } from '../lib/question-group';
|
||||
import { getRoadmapsByIds, RoadmapFrontmatter } from '../lib/roadmap';
|
||||
import { getRoadmapsByIds, type RoadmapFrontmatter } from '../lib/roadmap';
|
||||
import { Map, Clipboard } from 'lucide-react';
|
||||
|
||||
export interface Props {
|
||||
@@ -24,9 +24,6 @@ const relatedQuestionDetails = await getQuestionGroupsByIds(relatedQuestions);
|
||||
<span class='text-md flex items-center rounded-md border bg-white px-3 py-1 font-medium'>
|
||||
<Clipboard className='mr-1.5 text-black' size='17px' />
|
||||
Test your Knowledge
|
||||
<span class='ml-2 rounded-md border border-yellow-300 bg-yellow-100 px-1 py-0.5 text-xs uppercase'>
|
||||
New
|
||||
</span>
|
||||
</span>
|
||||
<a
|
||||
href='/questions'
|
||||
@@ -59,14 +56,19 @@ const relatedQuestionDetails = await getQuestionGroupsByIds(relatedQuestions);
|
||||
|
||||
{
|
||||
relatedRoadmaps.length && (
|
||||
<div class:list={['border-t bg-gray-100', {
|
||||
'mt-8': !relatedQuestionDetails.length
|
||||
}]}>
|
||||
<div
|
||||
class:list={[
|
||||
'border-t bg-gray-100',
|
||||
{
|
||||
'mt-8': !relatedQuestionDetails.length,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div class='container'>
|
||||
<div class='relative -top-5 flex justify-between'>
|
||||
<span class='text-md flex items-center rounded-md border bg-white px-3 py-1 font-medium'>
|
||||
<Map className='text-black mr-1.5' size='17px' />
|
||||
Related Roadmaps
|
||||
Related <span class='hidden sm:inline'>Roadmaps</span>
|
||||
</span>
|
||||
<a
|
||||
href='/roadmaps'
|
||||
|
||||
@@ -4,10 +4,11 @@ import { CopyIcon } from 'lucide-react';
|
||||
type EditorProps = {
|
||||
title: string;
|
||||
text: string;
|
||||
onCopy?: () => void;
|
||||
};
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
const { text, title } = props;
|
||||
const { text, title, onCopy } = props;
|
||||
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
|
||||
@@ -17,7 +18,13 @@ export function Editor(props: EditorProps) {
|
||||
<span className="text-xs uppercase leading-none text-gray-400">
|
||||
{title}
|
||||
</span>
|
||||
<button className="flex items-center" onClick={() => copyText(text)}>
|
||||
<button
|
||||
className="flex items-center"
|
||||
onClick={() => {
|
||||
copyText(text);
|
||||
onCopy?.();
|
||||
}}
|
||||
>
|
||||
{isCopied && (
|
||||
<span className="mr-1 text-xs leading-none text-gray-700">
|
||||
Copied!
|
||||
@@ -33,6 +40,7 @@ export function Editor(props: EditorProps) {
|
||||
onClick={(e: any) => {
|
||||
e.target.select();
|
||||
copyText(e.target.value);
|
||||
onCopy?.();
|
||||
}}
|
||||
value={text}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { SelectionButton } from './SelectionButton';
|
||||
import { StepCounter } from './StepCounter';
|
||||
import { Editor } from './Editor';
|
||||
import { CopyIcon } from 'lucide-react';
|
||||
import { httpPatch } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type StepLabelProps = {
|
||||
label: string;
|
||||
@@ -24,11 +26,28 @@ function StepLabel(props: StepLabelProps) {
|
||||
}
|
||||
|
||||
export function RoadCardPage() {
|
||||
const user = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
const [roadmaps, setRoadmaps] = useState<string[]>([]);
|
||||
const [version, setVersion] = useState<'tall' | 'wide'>('tall');
|
||||
const [variant, setVariant] = useState<'dark' | 'light'>('dark');
|
||||
const user = useAuth();
|
||||
|
||||
const markRoadCardDone = async () => {
|
||||
const { error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-onboarding-config`,
|
||||
{
|
||||
id: 'roadCard',
|
||||
status: 'done',
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
@@ -131,20 +150,24 @@ export function RoadCardPage() {
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
className="flex items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
downloadImage({
|
||||
url: badgeUrl.toString(),
|
||||
name: 'road-card',
|
||||
scale: 4,
|
||||
})
|
||||
}
|
||||
});
|
||||
markRoadCardDone();
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
disabled={isCopied}
|
||||
className="flex cursor-pointer items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50"
|
||||
onClick={() => copyText(badgeUrl.toString())}
|
||||
onClick={() => {
|
||||
copyText(badgeUrl.toString());
|
||||
markRoadCardDone();
|
||||
}}
|
||||
>
|
||||
<CopyIcon size={16} className="mr-1 inline-block h-4 w-4" />
|
||||
|
||||
@@ -156,11 +179,13 @@ export function RoadCardPage() {
|
||||
<Editor
|
||||
title={'HTML'}
|
||||
text={`<a href="https://roadmap.sh"><img src="${badgeUrl}" alt="roadmap.sh"/></a>`.trim()}
|
||||
onCopy={() => markRoadCardDone()}
|
||||
/>
|
||||
|
||||
<Editor
|
||||
title={'Markdown'}
|
||||
text={`[](https://roadmap.sh)`.trim()}
|
||||
onCopy={() => markRoadCardDone()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ const hasTnsBanner = !!tnsBannerLink;
|
||||
{
|
||||
isRoadmapReady && (
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new/choose`}
|
||||
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'
|
||||
|
||||
@@ -23,7 +23,7 @@ const hasTnsBanner = !!tnsBannerLink;
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 bg-white',
|
||||
'mb-0 mt-4 rounded-md border-0 bg-white sm:mt-7 sm:border',
|
||||
...(hasTnsBanner
|
||||
? [
|
||||
{
|
||||
@@ -42,7 +42,7 @@ const hasTnsBanner = !!tnsBannerLink;
|
||||
<ResourceProgressStats
|
||||
resourceId={roadmapId}
|
||||
resourceType='roadmap'
|
||||
hasSecondaryBanner={hasTitleQuestion}
|
||||
hasSecondaryBanner={Boolean(hasTitleQuestion)}
|
||||
/>
|
||||
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
|
||||
return (
|
||||
<div className="relative hidden border-t text-sm font-medium sm:block">
|
||||
{isAnswerVisible && (
|
||||
<div className="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>
|
||||
<div className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50"></div>
|
||||
)}
|
||||
<h2
|
||||
className="z-50 flex cursor-pointer items-center px-2 py-2.5 text-base font-medium"
|
||||
@@ -41,7 +41,7 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={`absolute left-0 right-0 top-0 z-50 mt-0 rounded-md border bg-white ${
|
||||
className={`absolute left-0 right-0 top-0 z-[100] mt-0 rounded-md border bg-white ${
|
||||
isAnswerVisible ? 'block' : 'hidden'
|
||||
}`}
|
||||
ref={ref}
|
||||
|
||||
@@ -77,6 +77,12 @@ const groups: GroupType[] = [
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development', 'Absolute Beginners'],
|
||||
},
|
||||
{
|
||||
title: 'API Design',
|
||||
link: '/api-design',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'QA',
|
||||
link: '/qa',
|
||||
@@ -216,6 +222,12 @@ const groups: GroupType[] = [
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Terraform',
|
||||
link: '/terraform',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -226,15 +238,20 @@ const groups: GroupType[] = [
|
||||
link: '/android',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'iOS',
|
||||
link: '/ios',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'React Native',
|
||||
link: '/react-native',
|
||||
type: 'role',
|
||||
type: 'skill',
|
||||
},
|
||||
{
|
||||
title: 'Flutter',
|
||||
link: '/flutter',
|
||||
type: 'role',
|
||||
type: 'skill',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -299,6 +316,16 @@ const groups: GroupType[] = [
|
||||
link: '/technical-writer',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'Product Manager',
|
||||
link: '/product-manager',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'DevRel Engineer',
|
||||
link: '/devrel',
|
||||
type: 'role',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -53,6 +53,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isTransferringToTeam, setIsTransferringToTeam] = useState(false);
|
||||
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
|
||||
const [friends, setFriends] = useState<ListFriendsResponse>([]);
|
||||
const [teams, setTeams] = useState<UserTeamItem[]>([]);
|
||||
@@ -71,13 +72,12 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
|
||||
const canTransferRoadmap = visibility === 'team' && !teamId;
|
||||
let isUpdateDisabled = false;
|
||||
// Disable update button if there are no friends to share with
|
||||
if (visibility === 'friends' && sharedFriendIds.length === 0) {
|
||||
isUpdateDisabled = true;
|
||||
// Disable update button if there are no team to transfer
|
||||
} else if (canTransferRoadmap && !selectedTeamId) {
|
||||
} else if (isTransferringToTeam && !selectedTeamId) {
|
||||
isUpdateDisabled = true;
|
||||
// Disable update button if there are no members to share with
|
||||
} else if (
|
||||
@@ -198,6 +198,8 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
</div>
|
||||
|
||||
<ShareOptionTabs
|
||||
isTransferringToTeam={isTransferringToTeam}
|
||||
setIsTransferringToTeam={setIsTransferringToTeam}
|
||||
visibility={visibility}
|
||||
setVisibility={setVisibility}
|
||||
teamId={teamId}
|
||||
@@ -226,48 +228,52 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex grow flex-col">
|
||||
{visibility === 'public' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Globe2 className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Anyone with the link can access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{visibility === 'me' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Lock className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Only you will be able to access.
|
||||
</p>
|
||||
</div>
|
||||
{!isTransferringToTeam && (
|
||||
<>
|
||||
{visibility === 'public' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Globe2 className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Anyone with the link can access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{visibility === 'me' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Lock className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Only you will be able to access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* For Personal Roadmap */}
|
||||
{visibility === 'friends' && (
|
||||
<ShareFriendList
|
||||
friends={friends}
|
||||
setFriends={setFriends}
|
||||
sharedFriendIds={sharedFriendIds}
|
||||
setSharedFriendIds={setSharedFriendIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* For Team Roadmap */}
|
||||
{visibility === 'team' && teamId && (
|
||||
<ShareTeamMemberList
|
||||
teamId={teamId}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
membersCache={membersCache}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* For Personal Roadmap */}
|
||||
{visibility === 'friends' && (
|
||||
<ShareFriendList
|
||||
friends={friends}
|
||||
setFriends={setFriends}
|
||||
sharedFriendIds={sharedFriendIds}
|
||||
setSharedFriendIds={setSharedFriendIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* For Team Roadmap */}
|
||||
{visibility === 'team' && teamId && (
|
||||
<ShareTeamMemberList
|
||||
teamId={teamId}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
membersCache={membersCache}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canTransferRoadmap && (
|
||||
{isTransferringToTeam && (
|
||||
<>
|
||||
<TransferToTeamList
|
||||
currentTeamId={teamId}
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
selectedTeamId={selectedTeamId}
|
||||
@@ -319,7 +325,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
Close
|
||||
</button>
|
||||
|
||||
{canTransferRoadmap && (
|
||||
{isTransferringToTeam && (
|
||||
<UpdateAction
|
||||
disabled={
|
||||
isUpdateDisabled || isLoading || sharedTeamMemberIds.length === 0
|
||||
@@ -335,7 +341,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
</UpdateAction>
|
||||
)}
|
||||
|
||||
{!canTransferRoadmap && (
|
||||
{!isTransferringToTeam && (
|
||||
<UpdateAction
|
||||
disabled={isUpdateDisabled || isLoading}
|
||||
onClick={() => {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { $teamList } from '../../stores/team.ts';
|
||||
import { useStore } from '@nanostores/react';
|
||||
|
||||
export const allowedVisibilityLabels: {
|
||||
id: AllowedRoadmapVisibility;
|
||||
@@ -44,15 +46,29 @@ export const allowedVisibilityLabels: {
|
||||
type ShareOptionTabsProps = {
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
setVisibility: (visibility: AllowedRoadmapVisibility) => void;
|
||||
|
||||
isTransferringToTeam: boolean;
|
||||
setIsTransferringToTeam: (isTransferringToTeam: boolean) => void;
|
||||
|
||||
teamId?: string;
|
||||
|
||||
onChange: (visibility: AllowedRoadmapVisibility) => void;
|
||||
};
|
||||
|
||||
export function ShareOptionTabs(props: ShareOptionTabsProps) {
|
||||
const { visibility, setVisibility, teamId, onChange } = props;
|
||||
const {
|
||||
isTransferringToTeam,
|
||||
setIsTransferringToTeam,
|
||||
visibility,
|
||||
setVisibility,
|
||||
teamId,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
const handleClick = (visibility: AllowedRoadmapVisibility) => {
|
||||
const teamList = useStore($teamList);
|
||||
|
||||
const handleTabClick = (visibility: AllowedRoadmapVisibility) => {
|
||||
setIsTransferringToTeam(false);
|
||||
setVisibility(visibility);
|
||||
onChange(visibility);
|
||||
};
|
||||
@@ -63,11 +79,9 @@ export function ShareOptionTabs(props: ShareOptionTabsProps) {
|
||||
{allowedVisibilityLabels.map((v) => {
|
||||
if (v.id === 'friends' && teamId) {
|
||||
return null;
|
||||
} else if (v.id === 'team' && !teamId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive = v.id === visibility;
|
||||
const isActive = !isTransferringToTeam && v.id === visibility;
|
||||
return (
|
||||
<li key={v.id}>
|
||||
<OptionTab
|
||||
@@ -75,21 +89,21 @@ export function ShareOptionTabs(props: ShareOptionTabsProps) {
|
||||
isActive={isActive}
|
||||
icon={v.icon}
|
||||
onClick={() => {
|
||||
handleClick(v.id);
|
||||
handleTabClick(v.id);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{!teamId && (
|
||||
{(!teamId || teamList.length > 1) && (
|
||||
<div className="grow">
|
||||
<OptionTab
|
||||
label="Transfer to team"
|
||||
icon={ArrowLeftRight}
|
||||
isActive={visibility === 'team'}
|
||||
isActive={isTransferringToTeam}
|
||||
onClick={() => {
|
||||
handleClick('team');
|
||||
setIsTransferringToTeam(true);
|
||||
}}
|
||||
className='border-red-300 text-red-600 hover:border-red-200 hover:bg-red-50 data-[active="true"]:border-red-600 data-[active="true"]:bg-red-600 data-[active="true"]:text-white'
|
||||
/>
|
||||
@@ -115,7 +129,7 @@ function OptionTab(props: OptionTabProps) {
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-black hover:border-gray-300 hover:bg-gray-100',
|
||||
'data-[active="true"]:border-gray-500 data-[active="true"]:bg-gray-200 data-[active="true"]:text-black',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
data-active={isActive}
|
||||
disabled={isActive}
|
||||
|
||||
@@ -82,25 +82,24 @@ export function ShareSuccess(props: ShareSuccessProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<p className="text-sm text-gray-400">
|
||||
You can also embed this roadmap on your website.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(embedHtml);
|
||||
}}
|
||||
readOnly={true}
|
||||
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
|
||||
value={embedHtml}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibility === 'public' && (
|
||||
<>
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<p className="text-sm text-gray-400">
|
||||
You can also embed this roadmap on your website.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(embedHtml);
|
||||
}}
|
||||
readOnly={true}
|
||||
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
|
||||
value={embedHtml}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-4 mt-4 flex items-center gap-1.5">
|
||||
<span className="h-px grow bg-gray-300" />
|
||||
<span className="px-2 text-xs uppercase text-gray-400">Or</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ type TransferToTeamListProps = {
|
||||
teams: UserTeamItem[];
|
||||
setTeams: (teams: UserTeamItem[]) => void;
|
||||
|
||||
currentTeamId?: string;
|
||||
selectedTeamId: string | null;
|
||||
setSelectedTeamId: (teamId: string | null) => void;
|
||||
|
||||
@@ -24,6 +25,7 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
selectedTeamId,
|
||||
setSelectedTeamId,
|
||||
isTeamMembersLoading,
|
||||
currentTeamId,
|
||||
setIsTeamMembersLoading,
|
||||
onTeamChange,
|
||||
} = props;
|
||||
@@ -38,7 +40,7 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<UserTeamItem[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`,
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
@@ -46,7 +48,7 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
}
|
||||
|
||||
setTeams(
|
||||
response.filter((team) => ['admin', 'manager'].includes(team.role))
|
||||
response.filter((team) => ['admin', 'manager'].includes(team.role)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,13 +82,16 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{teams.map((team) => {
|
||||
const isSelected = team._id === selectedTeamId;
|
||||
if (team._id === currentTeamId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={team._id}>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5 disabled:cursor-not-allowed disabled:opacity-70',
|
||||
isSelected && 'border-gray-500 bg-gray-100 text-black'
|
||||
isSelected && 'border-gray-500 bg-gray-100 text-black',
|
||||
)}
|
||||
disabled={isTeamMembersLoading}
|
||||
onClick={() => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useState } from 'react';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import type { TeamStreamActivity } from './TeamActivityPage';
|
||||
import { ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||
import { ActivityTopicTitles } from '../Activity/ActivityTopicTitles';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
|
||||
type TeamActivityItemProps = {
|
||||
onTopicClick?: (activity: TeamStreamActivity) => void;
|
||||
@@ -12,6 +16,7 @@ type TeamActivityItemProps = {
|
||||
name: string;
|
||||
avatar?: string | undefined;
|
||||
username?: string | undefined;
|
||||
memberId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -19,6 +24,7 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
const { user, onTopicClick, teamId } = props;
|
||||
const { activities } = user;
|
||||
|
||||
const currentTeam = useStore($currentTeam);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const resourceLink = (activity: TeamStreamActivity) => {
|
||||
@@ -59,21 +65,39 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const isPersonalProgressOnly =
|
||||
currentTeam?.personalProgressOnly &&
|
||||
currentTeam.role === 'member' &&
|
||||
user.memberId !== currentTeam.memberId;
|
||||
const username = (
|
||||
<>
|
||||
<a
|
||||
href={`/team/member?t=${teamId}&m=${user?.memberId}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 underline underline-offset-2 hover:underline',
|
||||
isPersonalProgressOnly
|
||||
? 'pointer-events-none cursor-default no-underline'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isPersonalProgressOnly) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={isPersonalProgressOnly}
|
||||
>
|
||||
<img
|
||||
className="mr-1 inline-block h-5 w-5 rounded-full"
|
||||
className="inline-block h-5 w-5 rounded-full"
|
||||
src={userAvatar}
|
||||
alt={user.name}
|
||||
/>
|
||||
<span className="font-medium">{user?.name || 'Unknown'}</span>{' '}
|
||||
</>
|
||||
<span className="font-medium">{user?.name || 'Unknown'}</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (activities.length === 1) {
|
||||
const activity = activities[0];
|
||||
const { actionType, topicIds } = activity;
|
||||
const topicCount = topicIds?.length || 0;
|
||||
const { actionType, topicTitles } = activity;
|
||||
const topicCount = topicTitles?.length || 0;
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -82,34 +106,45 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
>
|
||||
{actionType === 'in_progress' && (
|
||||
<>
|
||||
{username} started{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => onTopicClick?.(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
<p className="mb-1 flex w-full flex-wrap items-center">
|
||||
{username} started
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles
|
||||
className="pl-5"
|
||||
topicTitles={topicTitles || []}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{actionType === 'done' && (
|
||||
<>
|
||||
{username} completed{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => onTopicClick?.(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
<p className="mb-1 flex w-full flex-wrap items-center">
|
||||
{username} completed
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles
|
||||
className="pl-5"
|
||||
topicTitles={topicTitles || []}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{actionType === 'answered' && (
|
||||
<>
|
||||
{username} answered {topicCount} question
|
||||
{topicCount > 1 ? 's' : ''} in {resourceLink(activity)}{' '}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
<p className="mb-1 flex w-full flex-wrap items-center">
|
||||
{username} answered
|
||||
{topicCount} question{topicCount > 1 ? 's' : ''}
|
||||
in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles
|
||||
className="pl-5"
|
||||
topicTitles={topicTitles || []}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
@@ -125,46 +160,55 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
return (
|
||||
<li key={user._id} className="overflow-hidden rounded-md border">
|
||||
<h3 className="flex flex-wrap items-center gap-1 bg-gray-100 px-2 py-2.5 text-sm">
|
||||
{username} has {activities.length} updates in {uniqueResourcesCount}{' '}
|
||||
resource(s)
|
||||
{username} has {activities.length} updates in {uniqueResourcesCount}
|
||||
resource(s)
|
||||
</h3>
|
||||
<div className="py-3">
|
||||
<ul className="ml-2 flex flex-col gap-2 sm:ml-[36px]">
|
||||
{activities.slice(0, activityLimit).map((activity) => {
|
||||
const { actionType, topicIds } = activity;
|
||||
const topicCount = topicIds?.length || 0;
|
||||
<ul className="ml-2 flex flex-col divide-y pr-2 sm:ml-[36px]">
|
||||
{activities.slice(0, activityLimit).map((activity, counter) => {
|
||||
const { actionType, topicTitles } = activity;
|
||||
const topicCount = topicTitles?.length || 0;
|
||||
|
||||
return (
|
||||
<li key={activity._id} className="text-sm text-gray-600">
|
||||
<li
|
||||
key={activity._id}
|
||||
className={cn(
|
||||
'text-sm text-gray-600',
|
||||
counter === 0 ? 'pb-2.5' : 'py-2.5',
|
||||
counter === activities.length - 1 ? 'pb-0' : '',
|
||||
)}
|
||||
>
|
||||
{actionType === 'in_progress' && (
|
||||
<>
|
||||
Started{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => onTopicClick?.(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
<p className="mb-1">
|
||||
Started {topicCount} topic
|
||||
{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles topicTitles={topicTitles || []} />
|
||||
</>
|
||||
)}
|
||||
{actionType === 'done' && (
|
||||
<>
|
||||
Completed{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => onTopicClick?.(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
<p className="mb-1">
|
||||
Completed {topicCount} topic
|
||||
{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles topicTitles={topicTitles || []} />
|
||||
</>
|
||||
)}
|
||||
{actionType === 'answered' && (
|
||||
<>
|
||||
Answered {topicCount} question
|
||||
{topicCount > 1 ? 's' : ''} in {resourceLink(activity)}{' '}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
<p className="mb-1">
|
||||
Answered {topicCount} question
|
||||
{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles topicTitles={topicTitles || []} />
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -18,7 +18,7 @@ export type TeamStreamActivity = {
|
||||
resourceSlug?: string;
|
||||
isCustomResource?: boolean;
|
||||
actionType: AllowedActivityActionType;
|
||||
topicIds?: string[];
|
||||
topicTitles?: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
@@ -39,6 +39,7 @@ type GetTeamActivityResponse = {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
username?: string;
|
||||
memberId?: string;
|
||||
}[];
|
||||
activities: TeamActivityStreamDocument[];
|
||||
};
|
||||
@@ -102,7 +103,7 @@ export function TeamActivityPage() {
|
||||
return activities?.filter((activity) => {
|
||||
return (
|
||||
activity.activity.length > 0 &&
|
||||
activity.activity.some((t) => (t?.topicIds?.length || 0) > 0)
|
||||
activity.activity.some((t) => (t?.topicTitles?.length || 0) > 0)
|
||||
);
|
||||
});
|
||||
}, [activities]);
|
||||
@@ -137,7 +138,7 @@ export function TeamActivityPage() {
|
||||
const userActivities = uniqueActivities
|
||||
.filter((activity) => activity.userId === user._id)
|
||||
.flatMap((activity) => activity.activity)
|
||||
.filter((activity) => (activity?.topicIds?.length || 0) > 0)
|
||||
.filter((activity) => (activity?.topicTitles?.length || 0) > 0)
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.updatedAt).getTime() -
|
||||
@@ -188,10 +189,10 @@ export function TeamActivityPage() {
|
||||
Team Activity
|
||||
</h3>
|
||||
<ul className="mb-4 mt-2 flex flex-col gap-3">
|
||||
{usersWithActivities.map((user) => {
|
||||
{usersWithActivities.map((user, index) => {
|
||||
return (
|
||||
<TeamActivityItem
|
||||
key={user._id}
|
||||
key={`${user._id}-${index}`}
|
||||
user={user}
|
||||
teamId={teamId}
|
||||
onTopicClick={setSelectedActivity}
|
||||
|
||||
@@ -16,54 +16,10 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
topicIds = [],
|
||||
topicTitles = [],
|
||||
actionType,
|
||||
} = activity;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTopicTitles = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`,
|
||||
{
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
topicIds,
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Failed to load topic titles');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTopicTitles(response);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTopicTitles().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading || error) {
|
||||
return (
|
||||
<ModalLoader
|
||||
error={error!}
|
||||
text={'Loading topics..'}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let pageUrl = '';
|
||||
if (resourceType === 'roadmap') {
|
||||
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`;
|
||||
@@ -77,8 +33,6 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
|
||||
<Modal
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}}
|
||||
>
|
||||
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}>
|
||||
@@ -100,9 +54,7 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
|
||||
</a>
|
||||
</span>
|
||||
<ul className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto max-md:max-h-full">
|
||||
{topicIds.map((topicId) => {
|
||||
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
|
||||
|
||||
{topicTitles.map((topicTitle) => {
|
||||
const ActivityIcon =
|
||||
actionType === 'done'
|
||||
? Check
|
||||
@@ -111,7 +63,7 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
|
||||
: Check;
|
||||
|
||||
return (
|
||||
<li key={topicId} className="flex items-start gap-2">
|
||||
<li key={topicTitle} className="flex items-start gap-2">
|
||||
<ActivityIcon
|
||||
strokeWidth={3}
|
||||
className="relative top-[4px] text-green-500"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, List, ListTodo } from 'lucide-react';
|
||||
import { ListTodo } from 'lucide-react';
|
||||
|
||||
type TeamActivityItemProps = {
|
||||
teamId: string;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type UserTeamItem = {
|
||||
role: AllowedRoles;
|
||||
status: AllowedMemberStatus;
|
||||
memberId: string;
|
||||
personalProgressOnly?: boolean;
|
||||
};
|
||||
|
||||
export type TeamListResponse = UserTeamItem[];
|
||||
|
||||
215
src/components/TeamMemberDetails/TeamMemberDetailsPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||
import type { TeamActivityStreamDocument } from '../TeamActivity/TeamActivityPage';
|
||||
import { ResourceProgress } from '../Activity/ResourceProgress';
|
||||
import { ActivityStream } from '../Activity/ActivityStream';
|
||||
import { MemberRoleBadge } from '../TeamMembers/RoleBadge';
|
||||
import { TeamMemberEmptyPage } from './TeamMemberEmptyPage';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { MemberProgressModal } from '../TeamProgress/MemberProgressModal';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
import { MemberCustomProgressModal } from '../TeamProgress/MemberCustomProgressModal';
|
||||
|
||||
type GetTeamMemberProgressesResponse = TeamMemberDocument & {
|
||||
name: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
progresses: UserProgress[];
|
||||
};
|
||||
|
||||
type GetTeamMemberActivityResponse = {
|
||||
data: TeamActivityStreamDocument[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
currPage: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export function TeamMemberDetailsPage() {
|
||||
const { t: teamId, m: memberId } = getUrlParams() as { t: string; m: string };
|
||||
|
||||
const toast = useToast();
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
const [memberProgress, setMemberProgress] =
|
||||
useState<GetTeamMemberProgressesResponse | null>(null);
|
||||
const [memberActivity, setMemberActivity] =
|
||||
useState<GetTeamMemberActivityResponse | null>(null);
|
||||
const [currPage, setCurrPage] = useState(1);
|
||||
|
||||
const [selectedResource, setSelectedResource] = useState<{
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
isCustomResource?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const loadMemberProgress = async () => {
|
||||
const { response, error } = await httpGet<GetTeamMemberProgressesResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-progresses/${teamId}/${memberId}`,
|
||||
);
|
||||
if (error || !response) {
|
||||
pageProgressMessage.set('');
|
||||
toast.error(error?.message || 'Failed to load team member');
|
||||
return;
|
||||
}
|
||||
|
||||
setMemberProgress(response);
|
||||
};
|
||||
|
||||
const loadMemberActivity = async (currPage: number = 1) => {
|
||||
const { response, error } = await httpGet<GetTeamMemberActivityResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-activity/${teamId}/${memberId}`,
|
||||
{
|
||||
currPage,
|
||||
},
|
||||
);
|
||||
if (error || !response) {
|
||||
pageProgressMessage.set('');
|
||||
toast.error(error?.message || 'Failed to load team member activity');
|
||||
return;
|
||||
}
|
||||
|
||||
setMemberActivity(response);
|
||||
setCurrPage(response?.currPage || 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.allSettled([loadMemberProgress(), loadMemberActivity()]).finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
},
|
||||
);
|
||||
}, [teamId]);
|
||||
|
||||
if (!teamId || !memberId || !memberProgress || !memberActivity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const avatarUrl = memberProgress?.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${memberProgress?.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const ProgressModal =
|
||||
selectedResource && !selectedResource.isCustomResource
|
||||
? MemberProgressModal
|
||||
: MemberCustomProgressModal;
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedResource && (
|
||||
<ProgressModal
|
||||
teamId={teamId}
|
||||
member={{
|
||||
...memberProgress,
|
||||
_id: memberId,
|
||||
updatedAt: new Date(memberProgress.updatedAt).toISOString(),
|
||||
progress: memberProgress.progresses,
|
||||
}}
|
||||
resourceId={selectedResource.resourceId}
|
||||
resourceType={selectedResource.resourceType}
|
||||
isCustomResource={selectedResource.isCustomResource}
|
||||
onClose={() => setSelectedResource(null)}
|
||||
onShowMyProgress={() => {
|
||||
window.location.href = `/team/member?t=${teamId}&m=${currentTeam?.memberId}`;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={memberProgress?.name}
|
||||
className="h-14 w-14 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="mt-1 text-2xl font-medium">{memberProgress?.name}</h1>
|
||||
<p className="text-sm text-gray-500">{memberProgress?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{memberProgress?.progresses && memberProgress?.progresses?.length > 0 ? (
|
||||
<>
|
||||
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||
Progress Overview
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{memberProgress?.progresses?.map((progress) => {
|
||||
const learningCount = progress.learning || 0;
|
||||
const doneCount = progress.done || 0;
|
||||
const totalCount = progress.total || 0;
|
||||
const skippedCount = progress.skipped || 0;
|
||||
|
||||
return (
|
||||
<ResourceProgress
|
||||
key={progress.resourceId}
|
||||
isCustomResource={progress.isCustomResource!}
|
||||
doneCount={doneCount > totalCount ? totalCount : doneCount}
|
||||
learningCount={
|
||||
learningCount > totalCount ? totalCount : learningCount
|
||||
}
|
||||
totalCount={totalCount}
|
||||
skippedCount={skippedCount}
|
||||
resourceId={progress.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
updatedAt={progress.updatedAt}
|
||||
title={progress.resourceTitle}
|
||||
roadmapSlug={progress.roadmapSlug}
|
||||
showActions={false}
|
||||
onResourceClick={() => {
|
||||
setSelectedResource({
|
||||
resourceId: progress.resourceId,
|
||||
resourceType: progress.resourceType,
|
||||
isCustomResource: progress.isCustomResource,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<TeamMemberEmptyPage teamId={teamId} />
|
||||
)}
|
||||
|
||||
{memberActivity?.data && memberActivity?.data?.length > 0 ? (
|
||||
<>
|
||||
<ActivityStream
|
||||
className="mt-8 p-0 md:m-0 md:mb-4 md:mt-8 md:p-0"
|
||||
activities={
|
||||
memberActivity?.data?.flatMap((act) => act.activity) || []
|
||||
}
|
||||
onResourceClick={(resourceId, resourceType, isCustomResource) => {
|
||||
setSelectedResource({
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Pagination
|
||||
currPage={currPage}
|
||||
totalPages={memberActivity?.totalPages || 1}
|
||||
totalCount={memberActivity?.totalCount || 0}
|
||||
perPage={memberActivity?.perPage || 10}
|
||||
onPageChange={(page) => {
|
||||
pageProgressMessage.set('Loading Activity');
|
||||
loadMemberActivity(page).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
src/components/TeamMemberDetails/TeamMemberEmptyPage.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon';
|
||||
|
||||
type TeamMemberEmptyPageProps = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export function TeamMemberEmptyPage(props: TeamMemberEmptyPageProps) {
|
||||
const { teamId } = props;
|
||||
|
||||
return (
|
||||
<div className="rounded-md">
|
||||
<div className="flex flex-col items-center p-7 text-center">
|
||||
<RoadmapIcon className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
|
||||
|
||||
<h2 className="text-lg font-bold sm:text-xl">No Progress</h2>
|
||||
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
|
||||
Progress will appear here as they start tracking their{' '}
|
||||
<a
|
||||
href={`/team/roadmaps?t=${teamId}`}
|
||||
className="mt-4 text-blue-500 hover:underline"
|
||||
>
|
||||
Roadmaps
|
||||
</a>{' '}
|
||||
progress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
|
||||
|
||||
export function MemberRoleBadge({ role }: { role: AllowedRoles }) {
|
||||
type RoleBadgeProps = {
|
||||
role: AllowedRoles;
|
||||
className?: string;
|
||||
};
|
||||
export function MemberRoleBadge(props: RoleBadgeProps) {
|
||||
const { role, className } = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs sm:flex items-center capitalize ${['admin'].includes(role)
|
||||
? 'bg-blue-100 text-blue-700 '
|
||||
: 'bg-gray-100 text-gray-700 '
|
||||
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`}
|
||||
className={cn(
|
||||
`items-center rounded-full px-2 py-0.5 text-xs capitalize sm:flex ${
|
||||
['admin'].includes(role)
|
||||
? 'bg-blue-100 text-blue-700 '
|
||||
: 'bg-gray-100 text-gray-700 '
|
||||
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { MailIcon } from '../ReactIcons/MailIcon';
|
||||
import { MemberActionDropdown } from './MemberActionDropdown';
|
||||
import { MemberRoleBadge } from './RoleBadge';
|
||||
import type { TeamMemberItem } from './TeamMembersPage';
|
||||
import { $canManageCurrentTeam } from '../../stores/team';
|
||||
import { $canManageCurrentTeam, $currentTeam } from '../../stores/team';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type TeamMemberProps = {
|
||||
member: TeamMemberItem;
|
||||
@@ -29,6 +31,7 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
onSendProgressReminder,
|
||||
} = props;
|
||||
|
||||
const currentTeam = useStore($currentTeam);
|
||||
const canManageTeam = useStore($canManageCurrentTeam);
|
||||
const showNoProgressBadge = !member.hasProgress && member.status === 'joined';
|
||||
const allowProgressReminder =
|
||||
@@ -36,6 +39,10 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
!member.hasProgress &&
|
||||
member.status === 'joined' &&
|
||||
member.userId !== userId;
|
||||
const isPersonalProgressOnly =
|
||||
currentTeam?.personalProgressOnly &&
|
||||
currentTeam.role === 'member' &&
|
||||
String(member._id) !== currentTeam.memberId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,7 +66,23 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<h3 className="inline-grid grid-cols-[auto_auto_auto] items-center font-medium">
|
||||
<span className="truncate">{member.name}</span>
|
||||
<a
|
||||
href={`/team/member?t=${member.teamId}&m=${member._id}`}
|
||||
className={cn(
|
||||
'truncate',
|
||||
isPersonalProgressOnly
|
||||
? 'pointer-events-none cursor-default no-underline'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isPersonalProgressOnly) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={isPersonalProgressOnly}
|
||||
>
|
||||
{member.name}
|
||||
</a>
|
||||
{showNoProgressBadge && (
|
||||
<span className="ml-2 rounded-full bg-red-400 px-2 py-0.5 text-xs font-normal text-white">
|
||||
No Progress
|
||||
@@ -109,4 +132,4 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,10 @@ export function MemberCustomProgressModal(props: ProgressMapProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e?.currentTarget as HTMLDivElement;
|
||||
const target =
|
||||
node?.type === 'todo'
|
||||
? document.querySelector(`[data-id="${node.id}"]`)
|
||||
: (e?.currentTarget as HTMLDivElement);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
@@ -215,7 +218,7 @@ export function MemberCustomProgressModal(props: ProgressMapProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="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 className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div
|
||||
id="original-roadmap"
|
||||
className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto"
|
||||
|
||||