Compare commits
712 Commits
fix/activi
...
fix/index
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c944f777 | ||
|
|
bff7c4203a | ||
|
|
55b5639541 | ||
|
|
9c3539eb3a | ||
|
|
283a88e719 | ||
|
|
3f4a256e94 | ||
|
|
1019addbcd | ||
|
|
dcb8df908d | ||
|
|
8da3fb7220 | ||
|
|
b4111cefca | ||
|
|
e46f24e4e2 | ||
|
|
5b723198be | ||
|
|
a1996b6fb8 | ||
|
|
24533cc887 | ||
|
|
82ba5898a7 | ||
|
|
7dd8dfc70f | ||
|
|
c186289cde | ||
|
|
81aa63c098 | ||
|
|
4dc4bfb9ee | ||
|
|
a0c49edc80 | ||
|
|
8206a3594a | ||
|
|
49472a20c9 | ||
|
|
62598ec5cd | ||
|
|
068a896caf | ||
|
|
331c45446c | ||
|
|
5df2572f02 | ||
|
|
91be286f8e | ||
|
|
e114c2f246 | ||
|
|
4821f9ff6d | ||
|
|
5f9c3f2813 | ||
|
|
2787620c5a | ||
|
|
714263c184 | ||
|
|
34423f4e83 | ||
|
|
8e4baa02b1 | ||
|
|
adfdd1eabe | ||
|
|
2ab437077c | ||
|
|
16056db603 | ||
|
|
0f276bf03a | ||
|
|
8bc81b6381 | ||
|
|
a8dcdf60dd | ||
|
|
539e9e1a60 | ||
|
|
380a3cd3e6 | ||
|
|
30b60181d6 | ||
|
|
b02a284e49 | ||
|
|
dd86b912c9 | ||
|
|
f207fdc48c | ||
|
|
5859bf5c63 | ||
|
|
f4870885cc | ||
|
|
1cb49fc18e | ||
|
|
3a7f7a2355 | ||
|
|
b4d34ba65d | ||
|
|
d9c509f1eb | ||
|
|
8f4710d8f7 | ||
|
|
4b00f300af | ||
|
|
b0b01e7b83 | ||
|
|
c3972382af | ||
|
|
9f7d902e5c | ||
|
|
0ac616d18e | ||
|
|
77ed07eafd | ||
|
|
ba04fe112e | ||
|
|
5a2cb3ee8d | ||
|
|
2db553ca32 | ||
|
|
8f60bb58f6 | ||
|
|
cde6990d21 | ||
|
|
45e75af774 | ||
|
|
f05c0a36c0 | ||
|
|
23d40e2df7 | ||
|
|
361cc0bd4f | ||
|
|
ae7bff26cc | ||
|
|
36815aa8ea | ||
|
|
e07a829638 | ||
|
|
0a506b3ead | ||
|
|
fb2d007831 | ||
|
|
5cb5db0f16 | ||
|
|
3302c9ab3f | ||
|
|
e406d4121d | ||
|
|
918eb1dc9c | ||
|
|
8809354837 | ||
|
|
df64c0de51 | ||
|
|
334b17beef | ||
|
|
3e75feda6a | ||
|
|
358a80c457 | ||
|
|
37db7ebd5b | ||
|
|
c3ca762799 | ||
|
|
bab8739405 | ||
|
|
504fcd8126 | ||
|
|
3cb0d45764 | ||
|
|
75bd422ef4 | ||
|
|
76a9d67018 | ||
|
|
2fccb646b6 | ||
|
|
568a357b97 | ||
|
|
e69c53b49d | ||
|
|
9a758bc069 | ||
|
|
26fad32246 | ||
|
|
c7ed1bd59f | ||
|
|
f618ef0bf6 | ||
|
|
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": 1721257136269
|
||||
}
|
||||
}
|
||||
@@ -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,97 @@
|
||||
|
||||
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.
|
||||
|
||||
</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");
|
||||
}
|
||||
12019
package-lock.json
generated
Normal file
53
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.3",
|
||||
"@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.5",
|
||||
"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.3",
|
||||
"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.2",
|
||||
"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.1",
|
||||
"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": "^0.10.14",
|
||||
"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"
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"unified": "^11.0.5",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.43.1",
|
||||
"@playwright/test": "^1.45.2",
|
||||
"@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.7",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tsx": "^4.16.2"
|
||||
}
|
||||
}
|
||||
|
||||
10187
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
|
||||
39
src/api/roadmap.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { type APIContext } from 'astro';
|
||||
import { api } from './api.ts';
|
||||
import type { RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
|
||||
export type ListShowcaseRoadmapResponse = {
|
||||
data: Pick<
|
||||
RoadmapDocument,
|
||||
| '_id'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'slug'
|
||||
| 'creatorId'
|
||||
| 'visibility'
|
||||
| 'createdAt'
|
||||
| 'topicCount'
|
||||
| 'ratings'
|
||||
>[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
currPage: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export function roadmapApi(context: APIContext) {
|
||||
return {
|
||||
listShowcaseRoadmap: async function () {
|
||||
const searchParams = new URLSearchParams(context.url.searchParams);
|
||||
return api(context).get<ListShowcaseRoadmapResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-showcase-roadmap`,
|
||||
searchParams,
|
||||
);
|
||||
},
|
||||
isShowcaseRoadmap: async function (slug: string) {
|
||||
return api(context).get<{
|
||||
isShowcase: boolean;
|
||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-is-showcase-roadmap/${slug}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,16 +0,0 @@
|
||||
type AIAnnouncementProps = {};
|
||||
|
||||
export function AIAnnouncement(props: AIAnnouncementProps) {
|
||||
return (
|
||||
<a
|
||||
className="rounded-md border border-dashed border-purple-600 px-3 py-1.5 text-purple-400 transition-colors hover:border-purple-400 hover:text-purple-200"
|
||||
href="/ai"
|
||||
>
|
||||
<span className="relative -top-[1px] mr-1 text-xs font-semibold uppercase text-white">
|
||||
New
|
||||
</span>{' '}
|
||||
<span className={'hidden sm:inline'}>Generate visual roadmaps with AI</span>
|
||||
<span className={'inline text-sm sm:hidden'}>AI Roadmap Generator!</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function TriggerVerifyEmail() {
|
||||
Verifying your new Email
|
||||
</h2>
|
||||
<div className="text-sm sm:text-base">
|
||||
{isLoading && <p>Please wait while we verify your new Email..</p>}
|
||||
{isLoading && <p>Please wait while we verify your new Email.</p>}
|
||||
{error && <p className="text-red-700">{error}</p>}
|
||||
</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}
|
||||
@@ -240,14 +243,15 @@ export function CommandMenu() {
|
||||
const groupChanged = prevPage && prevPage.group !== page.group;
|
||||
|
||||
return (
|
||||
<Fragment key={page.id}>
|
||||
<Fragment key={page.group+'/'+page.id}>
|
||||
{groupChanged && (
|
||||
<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'}
|
||||
|
||||
@@ -23,24 +23,44 @@ export const allowedCustomRoadmapType = ['role', 'skill'] as const;
|
||||
export type AllowedCustomRoadmapType =
|
||||
(typeof allowedCustomRoadmapType)[number];
|
||||
|
||||
export const allowedShowcaseStatus = ['visible', 'hidden'] as const;
|
||||
export type AllowedShowcaseStatus = (typeof allowedShowcaseStatus)[number];
|
||||
|
||||
export interface RoadmapDocument {
|
||||
_id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
creatorId: string;
|
||||
aiRoadmapId?: string;
|
||||
teamId?: string;
|
||||
isDiscoverable: boolean;
|
||||
type: AllowedCustomRoadmapType;
|
||||
topicCount: number;
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
sharedFriendIds?: string[];
|
||||
sharedTeamMemberIds?: string[];
|
||||
feedbacks?: {
|
||||
userId: string;
|
||||
email: string;
|
||||
feedback: string;
|
||||
}[];
|
||||
metadata?: {
|
||||
originalRoadmapId?: string;
|
||||
defaultRoadmapId?: string;
|
||||
};
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
|
||||
isDiscoverable?: boolean;
|
||||
showcaseStatus?: AllowedShowcaseStatus;
|
||||
ratings: {
|
||||
average: number;
|
||||
breakdown: {
|
||||
[key: number]: number;
|
||||
};
|
||||
};
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
canManage: boolean;
|
||||
isCustomResource: boolean;
|
||||
}
|
||||
|
||||
interface CreateRoadmapModalProps {
|
||||
|
||||
@@ -15,6 +15,10 @@ export const allowedLinkTypes = [
|
||||
'course',
|
||||
'website',
|
||||
'podcast',
|
||||
'roadmap.sh',
|
||||
'official',
|
||||
'roadmap',
|
||||
'feed',
|
||||
] as const;
|
||||
|
||||
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
|
||||
@@ -43,6 +47,7 @@ export type GetRoadmapResponse = RoadmapDocument & {
|
||||
canManage: boolean;
|
||||
creator?: CreatorType;
|
||||
team?: CreatorType;
|
||||
unseenRatingCount: number;
|
||||
};
|
||||
|
||||
export function hideRoadmapLoader() {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { BadgeCheck, MessageCircleHeart, PencilRuler } from 'lucide-react';
|
||||
import {
|
||||
BadgeCheck,
|
||||
Heart,
|
||||
HeartHandshake,
|
||||
MessageCircleHeart,
|
||||
PencilRuler,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { showLoginPopup } from '../../lib/popup.ts';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import { useState } from 'react';
|
||||
@@ -17,14 +24,11 @@ export function CustomRoadmapAlert() {
|
||||
/>
|
||||
)}
|
||||
<div className="relative mb-5 mt-0 rounded-md border border-yellow-500 bg-yellow-100 p-2 sm:-mt-6 sm:mb-7 sm:p-2.5">
|
||||
<h2 className="text-base font-semibold text-yellow-800 sm:text-lg">
|
||||
Community Roadmap
|
||||
</h2>
|
||||
<p className="mt-2 mb-2.5 sm:mb-1.5 sm:mt-1 text-sm text-yellow-800 sm:text-base">
|
||||
This is a custom roadmap made by a community member and is not verified by{' '}
|
||||
<span className="font-semibold">roadmap.sh</span>
|
||||
<p className="mb-2.5 mt-2 text-sm text-yellow-800 sm:mb-1.5 sm:mt-1 sm:text-base">
|
||||
This is a custom roadmap made by a community member and is not
|
||||
verified by <span className="font-semibold">roadmap.sh</span>
|
||||
</p>
|
||||
<div className="flex items-start sm:items-center flex-col sm:flex-row gap-2">
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
|
||||
@@ -32,20 +36,16 @@ export function CustomRoadmapAlert() {
|
||||
<BadgeCheck className="h-4 w-4 stroke-[2.5]" />
|
||||
Visit Official Roadmaps
|
||||
</a>
|
||||
<span className="font-black text-yellow-700 hidden sm:block">·</span>
|
||||
<button
|
||||
<span className="hidden font-black text-yellow-700 sm:block">
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
href="/community"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
} else {
|
||||
setIsCreatingRoadmap(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PencilRuler className="h-4 w-4 stroke-[2.5]" />
|
||||
Create Your Own Roadmap
|
||||
</button>
|
||||
<HeartHandshake className="h-4 w-4 stroke-[2.5]" />
|
||||
More Community Roadmaps
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<MessageCircleHeart className="absolute bottom-2 right-2 hidden h-12 w-12 text-yellow-500 opacity-50 sm:block" />
|
||||
|
||||
90
src/components/CustomRoadmap/CustomRoadmapRatings.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { Rating } from '../Rating/Rating';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { CustomRoadmapRatingsModal } from './CustomRoadmapRatingsModal';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
type CustomRoadmapRatingsProps = {
|
||||
roadmapSlug: string;
|
||||
ratings: RoadmapDocument['ratings'];
|
||||
canManage?: boolean;
|
||||
unseenRatingCount: number;
|
||||
};
|
||||
|
||||
export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) {
|
||||
const { ratings, roadmapSlug, canManage, unseenRatingCount } = props;
|
||||
const average = ratings?.average || 0;
|
||||
|
||||
const totalPeopleWhoRated = Object.keys(ratings?.breakdown || {}).reduce(
|
||||
(acc, key) => acc + ratings?.breakdown[key as any],
|
||||
0,
|
||||
);
|
||||
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDetailsOpen && (
|
||||
<CustomRoadmapRatingsModal
|
||||
roadmapSlug={roadmapSlug}
|
||||
onClose={() => {
|
||||
setIsDetailsOpen(false);
|
||||
}}
|
||||
ratings={ratings}
|
||||
canManage={canManage}
|
||||
/>
|
||||
)}
|
||||
{average === 0 && (
|
||||
<>
|
||||
{!canManage && (
|
||||
<button
|
||||
className="flex h-[34px] items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium hover:border-black"
|
||||
onClick={() => {
|
||||
setIsDetailsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Star className="size-4 fill-yellow-400 text-yellow-400" />
|
||||
<span className="hidden md:block">Rate this roadmap</span>
|
||||
<span className="block md:hidden">Rate</span>
|
||||
</button>
|
||||
)}
|
||||
{canManage && (
|
||||
<span className="flex h-[34px] cursor-default items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium opacity-50">
|
||||
<Star className="size-4 fill-yellow-400 text-yellow-400" />
|
||||
<span className="hidden md:block">No ratings yet</span>
|
||||
<span className="block md:hidden">Rate</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{average > 0 && (
|
||||
<button
|
||||
className="relative flex h-[34px] items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium hover:border-black"
|
||||
onClick={() => {
|
||||
setIsDetailsOpen(true);
|
||||
}}
|
||||
>
|
||||
{average.toFixed(1)}
|
||||
<span className="hidden lg:block">
|
||||
<Rating
|
||||
starSize={16}
|
||||
rating={average}
|
||||
className={'pointer-events-none gap-px'}
|
||||
readOnly
|
||||
/>
|
||||
</span>
|
||||
<span className="lg:hidden">
|
||||
<Star className="size-5 fill-yellow-400 text-yellow-400" />
|
||||
</span>
|
||||
({totalPeopleWhoRated})
|
||||
{canManage && unseenRatingCount > 0 && (
|
||||
<span className="absolute right-0 top-0 flex size-4 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-red-500 text-[10px] font-medium leading-none text-white">
|
||||
{unseenRatingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
src/components/CustomRoadmap/CustomRoadmapRatingsModal.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '../Modal';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { RateRoadmapForm } from './RateRoadmapForm';
|
||||
import { ListRoadmapRatings } from './ListRoadmapRatings';
|
||||
|
||||
type ActiveTab = 'ratings' | 'feedback';
|
||||
|
||||
type CustomRoadmapRatingsModalProps = {
|
||||
onClose: () => void;
|
||||
roadmapSlug: string;
|
||||
ratings: RoadmapDocument['ratings'];
|
||||
canManage?: boolean;
|
||||
};
|
||||
|
||||
export function CustomRoadmapRatingsModal(
|
||||
props: CustomRoadmapRatingsModalProps,
|
||||
) {
|
||||
const { onClose, ratings, roadmapSlug, canManage = false } = props;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>(
|
||||
canManage ? 'feedback' : 'ratings',
|
||||
);
|
||||
|
||||
const tabs: {
|
||||
id: ActiveTab;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'ratings',
|
||||
label: 'Ratings',
|
||||
},
|
||||
{
|
||||
id: 'feedback',
|
||||
label: 'Feedback',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
bodyClassName="bg-transparent shadow-none"
|
||||
wrapperClassName="h-auto"
|
||||
overlayClassName="items-start md:items-center"
|
||||
>
|
||||
{activeTab === 'ratings' && (
|
||||
<RateRoadmapForm
|
||||
ratings={ratings}
|
||||
roadmapSlug={roadmapSlug}
|
||||
canManage={canManage}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'feedback' && (
|
||||
<ListRoadmapRatings ratings={ratings} roadmapSlug={roadmapSlug} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
181
src/components/CustomRoadmap/ListRoadmapRatings.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { Loader2, MessageCircle, ServerCrash } from 'lucide-react';
|
||||
import { Rating } from '../Rating/Rating';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { getRelativeTimeString } from '../../lib/date.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
import { Pagination } from '../Pagination/Pagination.tsx';
|
||||
|
||||
export interface RoadmapRatingDocument {
|
||||
_id?: string;
|
||||
roadmapId: string;
|
||||
userId: string;
|
||||
rating: number;
|
||||
feedback?: string;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
type ListRoadmapRatingsResponse = {
|
||||
data: (RoadmapRatingDocument & {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
})[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
currPage: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
type ListRoadmapRatingsProps = {
|
||||
roadmapSlug: string;
|
||||
ratings: RoadmapDocument['ratings'];
|
||||
};
|
||||
|
||||
export function ListRoadmapRatings(props: ListRoadmapRatingsProps) {
|
||||
const { roadmapSlug, ratings: ratingSummary } = props;
|
||||
|
||||
const totalWhoRated = Object.keys(ratingSummary.breakdown || {}).reduce(
|
||||
(acc, key) => acc + ratingSummary.breakdown[key as any],
|
||||
0,
|
||||
);
|
||||
const averageRating = ratingSummary.average;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [ratingsResponse, setRatingsResponse] =
|
||||
useState<ListRoadmapRatingsResponse | null>(null);
|
||||
|
||||
const listRoadmapRatings = async (currPage: number = 1) => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response, error } = await httpGet<ListRoadmapRatingsResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-roadmap-ratings/${roadmapSlug}`,
|
||||
{
|
||||
currPage,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response || error) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRatingsResponse(response);
|
||||
setError('');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
listRoadmapRatings().then();
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center bg-white py-10">
|
||||
<ServerCrash className="size-12 text-red-500" />
|
||||
<p className="mt-3 text-lg text-red-500">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ratings = ratingsResponse?.data || [];
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[100px] overflow-auto rounded-lg bg-white p-2 md:max-h-[550px]">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner isDualRing={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && ratings.length > 0 && (
|
||||
<div className="relative">
|
||||
<div className="sticky top-1.5 mb-2 flex items-center justify-center gap-1 rounded-lg bg-yellow-50 px-2 py-1.5 text-sm text-yellow-900">
|
||||
<span>
|
||||
Rated{' '}
|
||||
<span className="font-medium">{averageRating.toFixed(1)}</span>
|
||||
</span>
|
||||
<Rating starSize={15} rating={averageRating} readOnly />
|
||||
by{' '}
|
||||
<span className="font-medium">
|
||||
{totalWhoRated} user{totalWhoRated > 1 && 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-col">
|
||||
{ratings.map((rating) => {
|
||||
const userAvatar = rating?.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${rating.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const isLastRating =
|
||||
ratings[ratings.length - 1]._id === rating._id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rating._id}
|
||||
className={cn('px-2 py-2.5', {
|
||||
'border-b': !isLastRating,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<img
|
||||
src={userAvatar}
|
||||
alt={rating.name}
|
||||
className="h-4 w-4 rounded-full"
|
||||
/>
|
||||
<span className="text-sm font-medium">{rating.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{getRelativeTimeString(rating.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5">
|
||||
<Rating rating={rating.rating} readOnly />
|
||||
|
||||
{rating.feedback && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{rating.feedback}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
variant="minimal"
|
||||
totalCount={ratingsResponse?.totalCount || 1}
|
||||
currPage={ratingsResponse?.currPage || 1}
|
||||
totalPages={ratingsResponse?.totalPages || 1}
|
||||
perPage={ratingsResponse?.perPage || 1}
|
||||
onPageChange={(page) => {
|
||||
listRoadmapRatings(page).then();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && ratings.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-10">
|
||||
<MessageCircle className="size-12 text-gray-200" />
|
||||
<p className="mt-3 text-base text-gray-600">No Feedbacks</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
src/components/CustomRoadmap/RateRoadmapForm.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { formatCommaNumber } from '../../lib/number';
|
||||
import { Rating } from '../Rating/Rating';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { Loader2, Star } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
|
||||
type GetMyRoadmapRatingResponse = {
|
||||
id?: string;
|
||||
rating: number;
|
||||
feedback?: string;
|
||||
};
|
||||
|
||||
type RateRoadmapFormProps = {
|
||||
ratings: RoadmapDocument['ratings'];
|
||||
roadmapSlug: string;
|
||||
canManage?: boolean;
|
||||
};
|
||||
|
||||
export function RateRoadmapForm(props: RateRoadmapFormProps) {
|
||||
const { ratings, canManage = false, roadmapSlug } = props;
|
||||
const { breakdown = {}, average: _average } = ratings || {};
|
||||
const average = _average || 0;
|
||||
|
||||
const ratingsKeys = [5, 4, 3, 2, 1];
|
||||
const totalRatings = ratingsKeys.reduce(
|
||||
(total, rating) => total + breakdown?.[rating] || 0,
|
||||
0,
|
||||
);
|
||||
|
||||
// if no rating then only show the ratings breakdown if the user can manage the roadmap
|
||||
const showRatingsBreakdown = average > 0 || canManage;
|
||||
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [isRatingRoadmap, setIsRatingRoadmap] = useState(!showRatingsBreakdown);
|
||||
const [userRatingId, setUserRatingId] = useState<string | undefined>();
|
||||
const [userRating, setUserRating] = useState(0);
|
||||
const [userFeedback, setUserFeedback] = useState('');
|
||||
|
||||
const loadMyRoadmapRating = async () => {
|
||||
// user can't have the rating for their own roadmap
|
||||
if (canManage) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<GetMyRoadmapRatingResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-my-roadmap-rating/${roadmapSlug}`,
|
||||
);
|
||||
|
||||
if (!response || error) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserRatingId(response?.id);
|
||||
setUserRating(response?.rating);
|
||||
setUserFeedback(response?.feedback || '');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const submitMyRoadmapRating = async () => {
|
||||
if (userRating <= 0) {
|
||||
toast.error('At least give it a star');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
const path = userRatingId
|
||||
? 'v1-update-custom-roadmap-rating'
|
||||
: 'v1-rate-custom-roadmap';
|
||||
const { response, error } = await httpPost<{
|
||||
id: string;
|
||||
}>(`${import.meta.env.PUBLIC_API_URL}/${path}/${roadmapSlug}`, {
|
||||
rating: userRating,
|
||||
feedback: userFeedback,
|
||||
});
|
||||
|
||||
if (!response || error) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn() || !roadmapSlug) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
loadMyRoadmapRating().then();
|
||||
}, [roadmapSlug]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{showRatingsBreakdown && !isRatingRoadmap && (
|
||||
<>
|
||||
<ul className="flex flex-col gap-1 rounded-lg bg-white p-5">
|
||||
{ratingsKeys.map((rating) => {
|
||||
const percentage =
|
||||
totalRatings <= 0
|
||||
? 0
|
||||
: ((breakdown?.[rating] || 0) / totalRatings) * 100;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`rating-${rating}`}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span className="shrink-0">{rating} star</span>
|
||||
<div className="relative h-8 w-full overflow-hidden rounded-md border">
|
||||
<div
|
||||
className="h-full bg-yellow-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
|
||||
{percentage > 0 && (
|
||||
<span className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center justify-center text-xs text-black">
|
||||
{formatCommaNumber(breakdown?.[rating] || 0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="w-[35px] shrink-0 text-xs text-gray-500">
|
||||
{parseInt(`${percentage}`, 10)}%
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!canManage && !isRatingRoadmap && (
|
||||
<div className="relative min-h-[100px] rounded-lg bg-white p-4">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner isDualRing={false} className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isRatingRoadmap && !userRatingId && (
|
||||
<>
|
||||
<p className="mb-2 text-center text-sm font-medium">
|
||||
Rate and share your thoughts with the roadmap creator.
|
||||
</p>
|
||||
<button
|
||||
className="flex h-10 w-full items-center justify-center rounded-full bg-black p-2.5 text-sm font-medium text-white disabled:opacity-60"
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRatingRoadmap(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
'Rate Roadmap'
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && !isRatingRoadmap && userRatingId && (
|
||||
<div>
|
||||
<h3 className="mb-2.5 flex items-center justify-between text-base font-semibold">
|
||||
Your Feedback
|
||||
<button
|
||||
className="ml-2 text-sm font-medium text-blue-500 underline underline-offset-2"
|
||||
onClick={() => {
|
||||
setIsRatingRoadmap(true);
|
||||
}}
|
||||
>
|
||||
Edit Rating
|
||||
</button>
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Rating rating={userRating} starSize={19} readOnly /> (
|
||||
{userRating})
|
||||
</div>
|
||||
{userFeedback && <p className="mt-2 text-sm">{userFeedback}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canManage && isRatingRoadmap && (
|
||||
<div className="rounded-lg bg-white p-5">
|
||||
<h3 className="font-semibold">Rate this roadmap</h3>
|
||||
<p className="mt-1 text-sm">
|
||||
Share your thoughts with the roadmap creator.
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="mt-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitMyRoadmapRating().then();
|
||||
}}
|
||||
>
|
||||
<Rating
|
||||
rating={userRating}
|
||||
onRatingChange={(rating) => {
|
||||
setUserRating(rating);
|
||||
}}
|
||||
starSize={32}
|
||||
/>
|
||||
<div className="mt-3 flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="rating-feedback"
|
||||
className="block text-sm font-medium"
|
||||
>
|
||||
Feedback to Creator{' '}
|
||||
<span className="font-normal text-gray-400">(Optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="rating-feedback"
|
||||
className="min-h-24 rounded-md border p-2 text-sm outline-none focus:border-gray-500"
|
||||
placeholder="Share your thoughts with the roadmap creator"
|
||||
value={userFeedback}
|
||||
onChange={(e) => {
|
||||
setUserFeedback(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cn('mt-4 grid grid-cols-2 gap-1')}>
|
||||
<button
|
||||
className="h-10 w-full rounded-full border p-2.5 text-sm font-medium disabled:opacity-60"
|
||||
onClick={() => {
|
||||
setIsRatingRoadmap(false);
|
||||
}}
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="flex h-10 w-full items-center justify-center rounded-full bg-black p-2.5 text-sm font-medium text-white disabled:opacity-60"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : userRatingId ? (
|
||||
'Update Rating'
|
||||
) : (
|
||||
'Submit Rating'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
|
||||
import { Lock, MoreVertical, PenSquare, Shapes, Trash2 } from 'lucide-react';
|
||||
|
||||
type RoadmapActionButtonProps = {
|
||||
onDelete?: () => void;
|
||||
@@ -32,9 +32,23 @@ export function RoadmapActionButton(props: RoadmapActionButtonProps) {
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="align-right absolute right-0 top-full mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md z-[9999]"
|
||||
className="align-right absolute right-0 top-full z-[9999] mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
|
||||
>
|
||||
<ul>
|
||||
{onCustomize && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onCustomize();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<PenSquare size={14} className="mr-2" />
|
||||
Edit
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onUpdateSharing && (
|
||||
<li>
|
||||
<button
|
||||
@@ -49,20 +63,6 @@ export function RoadmapActionButton(props: RoadmapActionButtonProps) {
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onCustomize && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onCustomize();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Shapes size={14} className="mr-2" />
|
||||
Customize
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onDelete && (
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -8,11 +8,9 @@ import { httpDelete, httpPut } from '../../lib/http';
|
||||
import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { RoadmapActionButton } from './RoadmapActionButton';
|
||||
import { Lock, Shapes } from 'lucide-react';
|
||||
import { Modal } from '../Modal';
|
||||
import { ShareSuccess } from '../ShareOptions/ShareSuccess';
|
||||
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
|
||||
import { CustomRoadmapAlert } from './CustomRoadmapAlert.tsx';
|
||||
import { CustomRoadmapRatings } from './CustomRoadmapRatings.tsx';
|
||||
|
||||
type RoadmapHeaderProps = {};
|
||||
|
||||
@@ -28,10 +26,12 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
creator,
|
||||
team,
|
||||
visibility,
|
||||
ratings,
|
||||
unseenRatingCount,
|
||||
showcaseStatus,
|
||||
} = useStore(currentRoadmap) || {};
|
||||
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [isSharingWithOthers, setIsSharingWithOthers] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
async function deleteResource() {
|
||||
@@ -72,23 +72,6 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${creator?.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const sharingWithOthersModal = isSharingWithOthers && (
|
||||
<Modal
|
||||
onClose={() => setIsSharingWithOthers(false)}
|
||||
wrapperClassName="max-w-lg"
|
||||
bodyClassName="p-4 flex flex-col"
|
||||
>
|
||||
<ShareSuccess
|
||||
visibility="public"
|
||||
roadmapSlug={roadmapSlug}
|
||||
roadmapId={roadmapId!}
|
||||
description={description}
|
||||
onClose={() => setIsSharingWithOthers(false)}
|
||||
isSharingWithOthers={true}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container relative py-5 sm:py-12">
|
||||
@@ -127,11 +110,12 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
<div className="flex justify-between gap-2 sm:gap-0">
|
||||
<div className="flex justify-stretch gap-1 sm:gap-2">
|
||||
<a
|
||||
href="/roadmaps"
|
||||
href="/community"
|
||||
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
aria-label="Back to All Roadmaps"
|
||||
>
|
||||
←<span className="hidden sm:inline"> All Roadmaps</span>
|
||||
←
|
||||
<span className="hidden sm:inline"> Discover more</span>
|
||||
</a>
|
||||
|
||||
<ShareRoadmapButton
|
||||
@@ -166,26 +150,13 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${$currentRoadmap?._id}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
|
||||
>
|
||||
<Shapes className="mr-1.5 h-4 w-4 stroke-[2.5]" />
|
||||
<span className="hidden sm:inline-block">Edit Roadmap</span>
|
||||
<span className="sm:hidden">Edit</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setIsSharing(true)}
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
|
||||
>
|
||||
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
|
||||
Sharing
|
||||
</button>
|
||||
|
||||
<RoadmapActionButton
|
||||
onUpdateSharing={() => setIsSharing(true)}
|
||||
onCustomize={() => {
|
||||
window.location.href = `${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${$currentRoadmap?._id}`;
|
||||
}}
|
||||
onDelete={() => {
|
||||
const confirmation = window.confirm(
|
||||
'Are you sure you want to delete this roadmap?',
|
||||
@@ -201,17 +172,13 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{!$canManageCurrentRoadmap && visibility === 'public' && (
|
||||
<>
|
||||
{sharingWithOthersModal}
|
||||
<button
|
||||
onClick={() => setIsSharingWithOthers(true)}
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
|
||||
>
|
||||
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
|
||||
Share with Others
|
||||
</button>
|
||||
</>
|
||||
{((ratings?.average || 0) > 0 || showcaseStatus === 'visible') && (
|
||||
<CustomRoadmapRatings
|
||||
roadmapSlug={roadmapSlug!}
|
||||
ratings={ratings!}
|
||||
canManage={$canManageCurrentRoadmap}
|
||||
unseenRatingCount={unseenRatingCount || 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,8 @@ export function SkeletonRoadmapHeader() {
|
||||
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[85px]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139.71px]" />
|
||||
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[100.34px]" />
|
||||
<div className="h-7 w-[32px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[89.73px]" />
|
||||
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[92px]" />
|
||||
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
21
src/components/DiscoverRoadmaps/DiscoverError.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
||||
|
||||
type DiscoverErrorProps = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function DiscoverError(props: DiscoverErrorProps) {
|
||||
const { message } = props;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl border px-5 py-3 sm:px-0 sm:py-20">
|
||||
<ErrorIcon additionalClasses="mb-4 h-8 w-8 sm:h-14 sm:w-14" />
|
||||
<h2 className="mb-1 text-lg font-semibold sm:text-xl">
|
||||
Oops! Something went wrong
|
||||
</h2>
|
||||
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/components/DiscoverRoadmaps/DiscoverRoadmapSorting.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ArrowDownWideNarrow, Check, ChevronDown } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import type { SortByValues } from './DiscoverRoadmaps';
|
||||
|
||||
const sortingLabels: { label: string; value: SortByValues }[] = [
|
||||
{
|
||||
label: 'Newest',
|
||||
value: 'createdAt',
|
||||
},
|
||||
{
|
||||
label: 'Oldest',
|
||||
value: '-createdAt',
|
||||
},
|
||||
{
|
||||
label: 'Highest Rated',
|
||||
value: 'rating',
|
||||
},
|
||||
{
|
||||
label: 'Lowest Rated',
|
||||
value: '-rating',
|
||||
},
|
||||
];
|
||||
|
||||
type DiscoverRoadmapSortingProps = {
|
||||
sortBy: SortByValues;
|
||||
onSortChange: (sortBy: SortByValues) => void;
|
||||
};
|
||||
|
||||
export function DiscoverRoadmapSorting(props: DiscoverRoadmapSortingProps) {
|
||||
const { sortBy, onSortChange } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const selectedValue = sortingLabels.find((item) => item.value === sortBy);
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-auto relative flex flex-shrink-0 sm:min-w-[140px]"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<button
|
||||
className="py-15 flex w-full items-center justify-between gap-2 rounded-md border px-2 text-sm bg-white"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span>{selectedValue?.label}</span>
|
||||
|
||||
<span>
|
||||
<ChevronDown className="ml-4 h-3.5 w-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-10 z-10 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
|
||||
{sortingLabels.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
onSortChange(item.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.value === sortBy && <Check className="ml-auto h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { Shapes } from 'lucide-react';
|
||||
import type { ListShowcaseRoadmapResponse } from '../../api/roadmap';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import { SearchRoadmap } from './SearchRoadmap';
|
||||
import { EmptyDiscoverRoadmaps } from './EmptyDiscoverRoadmaps';
|
||||
import { Rating } from '../Rating/Rating';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { LoadingRoadmaps } from '../ExploreAIRoadmap/LoadingRoadmaps';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { DiscoverRoadmapSorting } from './DiscoverRoadmapSorting';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
import { Tooltip } from '../Tooltip.tsx';
|
||||
|
||||
type DiscoverRoadmapsProps = {};
|
||||
|
||||
export type SortByValues = 'rating' | '-rating' | 'createdAt' | '-createdAt';
|
||||
|
||||
type QueryParams = {
|
||||
q?: string;
|
||||
s?: SortByValues;
|
||||
p?: string;
|
||||
};
|
||||
|
||||
type PageState = {
|
||||
searchTerm: string;
|
||||
sortBy: SortByValues;
|
||||
currentPage: number;
|
||||
};
|
||||
|
||||
export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) {
|
||||
const toast = useToast();
|
||||
|
||||
const [pageState, setPageState] = useState<PageState>({
|
||||
searchTerm: '',
|
||||
sortBy: 'createdAt',
|
||||
currentPage: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [roadmapsResponse, setRoadmapsResponse] =
|
||||
useState<ListShowcaseRoadmapResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const queryParams = getUrlParams() as QueryParams;
|
||||
|
||||
setPageState({
|
||||
searchTerm: queryParams.q || '',
|
||||
sortBy: queryParams.s || 'createdAt',
|
||||
currentPage: +(queryParams.p || '1'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
if (!pageState.currentPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// only set the URL params if the user modified anything
|
||||
if (
|
||||
pageState.currentPage !== 1 ||
|
||||
pageState.searchTerm !== '' ||
|
||||
pageState.sortBy !== 'createdAt'
|
||||
) {
|
||||
setUrlParams({
|
||||
q: pageState.searchTerm,
|
||||
s: pageState.sortBy,
|
||||
p: String(pageState.currentPage),
|
||||
});
|
||||
} else {
|
||||
deleteUrlParam('q');
|
||||
deleteUrlParam('s');
|
||||
deleteUrlParam('p');
|
||||
}
|
||||
|
||||
loadAIRoadmaps(
|
||||
pageState.currentPage,
|
||||
pageState.searchTerm,
|
||||
pageState.sortBy,
|
||||
).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [pageState]);
|
||||
|
||||
const loadAIRoadmaps = async (
|
||||
currPage: number = 1,
|
||||
searchTerm: string = '',
|
||||
sortBy: SortByValues = 'createdAt',
|
||||
) => {
|
||||
const { response, error } = await httpGet<ListShowcaseRoadmapResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-showcase-roadmap`,
|
||||
{
|
||||
currPage,
|
||||
...(searchTerm && { searchTerm }),
|
||||
...(sortBy && { sortBy }),
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setRoadmapsResponse(response);
|
||||
};
|
||||
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
const roadmaps = roadmapsResponse?.data || [];
|
||||
|
||||
const loadingIndicator = isLoading && <LoadingRoadmaps />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="border-b bg-white pt-10 pb-7">
|
||||
<div className="container text-left">
|
||||
<div className="flex flex-col items-start bg-white">
|
||||
<h1 className="mb-1 text-2xl font-bold sm:text-4xl">
|
||||
Community Roadmaps
|
||||
</h1>
|
||||
<p className="mb-3 text-base text-gray-500">
|
||||
An unvetted, selected list of community-curated roadmaps
|
||||
</p>
|
||||
<div className="relative">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-1.5">
|
||||
<span className="group relative normal-case">
|
||||
<Tooltip
|
||||
position={'bottom-left'}
|
||||
additionalClass={
|
||||
'translate-y-0.5 bg-yellow-300 font-normal !text-black'
|
||||
}
|
||||
>
|
||||
Ask us to feature it once you're done!
|
||||
</Tooltip>
|
||||
<button
|
||||
className="rounded-md bg-black px-3.5 py-1.5 text-sm font-medium text-white transition-colors hover:bg-black"
|
||||
onClick={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
}}
|
||||
>
|
||||
Create your own roadmap
|
||||
</button>
|
||||
</span>
|
||||
<span className="group relative normal-case">
|
||||
<Tooltip
|
||||
position={'bottom-left'}
|
||||
additionalClass={
|
||||
'translate-y-0.5 bg-yellow-300 font-normal !text-black'
|
||||
}
|
||||
>
|
||||
Up-to-date and maintained by the official team
|
||||
</Tooltip>
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="inline-block rounded-md bg-gray-300 px-3.5 py-1.5 text-sm text-black sm:py-1.5 sm:text-sm"
|
||||
>
|
||||
Visit our official roadmaps
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 py-3">
|
||||
<section className="container mx-auto py-3">
|
||||
<div className="mb-3.5 flex items-stretch justify-between gap-2.5">
|
||||
<SearchRoadmap
|
||||
total={roadmapsResponse?.totalCount || 0}
|
||||
value={pageState.searchTerm}
|
||||
isLoading={isLoading}
|
||||
onValueChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
searchTerm: value,
|
||||
currentPage: 1,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<DiscoverRoadmapSorting
|
||||
sortBy={pageState.sortBy}
|
||||
onSortChange={(sortBy) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
sortBy,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loadingIndicator}
|
||||
{roadmaps.length === 0 && !isLoading && <EmptyDiscoverRoadmaps />}
|
||||
{roadmaps.length > 0 && !isLoading && (
|
||||
<>
|
||||
<ul className="mb-4 grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{roadmaps.map((roadmap) => {
|
||||
const roadmapLink = `/r/${roadmap.slug}`;
|
||||
const totalRatings = Object.keys(
|
||||
roadmap.ratings?.breakdown || [],
|
||||
).reduce(
|
||||
(acc: number, key: string) =>
|
||||
acc + roadmap.ratings.breakdown[key as any],
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<li key={roadmap._id} className="h-full min-h-[175px]">
|
||||
<a
|
||||
key={roadmap._id}
|
||||
href={roadmapLink}
|
||||
className="flex h-full flex-col rounded-lg border bg-white p-3.5 transition-colors hover:border-gray-300 hover:bg-gray-50"
|
||||
target={'_blank'}
|
||||
>
|
||||
<div className="grow">
|
||||
<h2 className="text-balance text-base font-bold leading-tight">
|
||||
{roadmap.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{roadmap.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<Shapes size={15} className="inline-block" />
|
||||
{Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
}).format(roadmap.topicCount)}{' '}
|
||||
</span>
|
||||
|
||||
<Rating
|
||||
rating={roadmap?.ratings?.average || 0}
|
||||
readOnly={true}
|
||||
starSize={16}
|
||||
total={totalRatings}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<Pagination
|
||||
currPage={roadmapsResponse?.currPage || 1}
|
||||
totalPages={roadmapsResponse?.totalPages || 1}
|
||||
perPage={roadmapsResponse?.perPage || 0}
|
||||
totalCount={roadmapsResponse?.totalCount || 0}
|
||||
onPageChange={(page) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
currentPage: page,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
src/components/DiscoverRoadmaps/EmptyDiscoverRoadmaps.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Map, Wand2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
|
||||
export function EmptyDiscoverRoadmaps() {
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
const creatingRoadmapModal = isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
onClose={() => setIsCreatingRoadmap(false)}
|
||||
onCreated={(roadmap) => {
|
||||
window.location.href = `${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${roadmap?._id}`;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{creatingRoadmapModal}
|
||||
|
||||
<div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl border px-5 py-3 sm:px-0 sm:py-20 bg-white">
|
||||
<Map className="mb-4 h-8 w-8 opacity-10 sm:h-14 sm:w-14" />
|
||||
<h2 className="mb-1 text-lg font-semibold sm:text-xl">
|
||||
No Roadmaps Found
|
||||
</h2>
|
||||
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm">
|
||||
Try searching for something else or create a new roadmap.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-1 sm:flex-row sm:gap-1.5">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 rounded-md bg-gray-900 px-3 py-1.5 text-xs text-white sm:w-auto sm:text-sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
}}
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
Create your Roadmap
|
||||
</button>
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="flex w-full items-center gap-1.5 rounded-md bg-gray-300 px-3 py-1.5 text-xs text-black hover:bg-gray-400 sm:w-auto sm:text-sm"
|
||||
>
|
||||
<Map className="h-4 w-4" />
|
||||
Visit Official Roadmaps
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
src/components/DiscoverRoadmaps/SearchRoadmap.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDebounceValue } from '../../hooks/use-debounce';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
|
||||
type SearchRoadmapProps = {
|
||||
value: string;
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
onValueChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function SearchRoadmap(props: SearchRoadmapProps) {
|
||||
const { total, value: defaultValue, onValueChange, isLoading } = props;
|
||||
|
||||
const [term, setTerm] = useState(defaultValue);
|
||||
const debouncedTerm = useDebounceValue(term, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setTerm(defaultValue);
|
||||
}, [defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTerm && debouncedTerm.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedTerm === defaultValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
onValueChange(debouncedTerm);
|
||||
}, [debouncedTerm]);
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full items-center gap-3">
|
||||
<form
|
||||
className="relative flex w-full max-w-[310px] items-center"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onValueChange(term);
|
||||
}}
|
||||
>
|
||||
<label
|
||||
className="absolute left-3 flex h-full items-center text-gray-500"
|
||||
htmlFor="search"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</label>
|
||||
<input
|
||||
id="q"
|
||||
name="q"
|
||||
type="text"
|
||||
minLength={3}
|
||||
placeholder="Type 3 or more characters to search..."
|
||||
className="w-full rounded-md border border-gray-200 px-3 py-2 pl-9 text-sm transition-colors focus:border-black focus:outline-none"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
{isLoading && (
|
||||
<span className="absolute right-3 top-0 flex h-full items-center text-gray-500">
|
||||
<Spinner isDualRing={false} className={`h-3 w-3`} />
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
{total > 0 && (
|
||||
<p className="hidden flex-shrink-0 text-sm text-gray-500 sm:block">
|
||||
{Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
}).format(total)}{' '}
|
||||
result{total > 1 ? 's' : ''} found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export function LoadingRoadmaps() {
|
||||
{new Array(21).fill(0).map((_, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="h-[95px] animate-pulse rounded-md border bg-gray-100"
|
||||
className="h-[175px] animate-pulse rounded-md border bg-gray-200"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
20
src/components/FeatureAnnouncement.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
type AIAnnouncementProps = {};
|
||||
|
||||
export function FeatureAnnouncement(props: AIAnnouncementProps) {
|
||||
return (
|
||||
<a
|
||||
className="rounded-md border border-dashed border-purple-600 px-3 py-1.5 text-purple-400 transition-colors hover:border-purple-400 hover:text-purple-200"
|
||||
href="/community"
|
||||
>
|
||||
<span className="relative -top-[1px] mr-1 text-xs font-semibold uppercase text-white">
|
||||
New
|
||||
</span>{' '}
|
||||
<span className={'hidden sm:inline'}>
|
||||
Explore community made roadmaps
|
||||
</span>
|
||||
<span className={'inline text-sm sm:hidden'}>
|
||||
Community roadmaps explorer!
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { AIAnnouncement } from '../AIAnnouncement.tsx';
|
||||
import { FeatureAnnouncement } from '../FeatureAnnouncement.tsx';
|
||||
|
||||
type EmptyProgressProps = {
|
||||
title?: string;
|
||||
@@ -23,7 +23,7 @@ export function EmptyProgress(props: EmptyProgressProps) {
|
||||
<p className={'text-sm text-gray-400 sm:text-base'}>{message}</p>
|
||||
|
||||
<p className="mt-5">
|
||||
<AIAnnouncement />
|
||||
<FeatureAnnouncement />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MapIcon, Users2 } from 'lucide-react';
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { AIAnnouncement } from '../AIAnnouncement.tsx';
|
||||
import { FeatureAnnouncement } from '../FeatureAnnouncement.tsx';
|
||||
|
||||
type ProgressRoadmapProps = {
|
||||
url: string;
|
||||
@@ -97,7 +97,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
||||
return (
|
||||
<div className="relative pb-12 pt-4 sm:pt-7">
|
||||
<p className="mb-7 mt-2 text-sm">
|
||||
<AIAnnouncement />
|
||||
<FeatureAnnouncement />
|
||||
</p>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
|
||||
import { AIAnnouncement } from "../AIAnnouncement";
|
||||
import { FeatureAnnouncement } from "../FeatureAnnouncement";
|
||||
---
|
||||
|
||||
<div
|
||||
@@ -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'>
|
||||
<AIAnnouncement />
|
||||
<p class='-mt-4 mb-7 sm:-mt-10 sm:mb-4'>
|
||||
<FeatureAnnouncement />
|
||||
</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"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Menu } from 'lucide-react';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
import { NavigationDropdown } from '../NavigationDropdown';
|
||||
import { AccountDropdown } from './AccountDropdown';
|
||||
import NewIndicator from './NewIndicator.astro';
|
||||
---
|
||||
|
||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||
@@ -17,20 +18,20 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
</a>
|
||||
|
||||
<a
|
||||
href='/ai'
|
||||
class='group inline sm:hidden relative !mr-2 text-blue-300 hover:text-white'
|
||||
href='/teams'
|
||||
class='group relative !mr-2 inline text-blue-300 hover:text-white sm:hidden'
|
||||
>
|
||||
AI Roadmaps
|
||||
Teams
|
||||
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
|
||||
></span>
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
|
||||
></span>
|
||||
</span>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
|
||||
></span>
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation items -->
|
||||
@@ -39,30 +40,27 @@ 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'
|
||||
href='/teams'
|
||||
class='group relative text-gray-400 hover:text-white'
|
||||
>
|
||||
Teams
|
||||
</a>
|
||||
<a href='/ai' class='text-gray-400 hover:text-white'> AI</a>
|
||||
<a
|
||||
href='/community'
|
||||
class='group relative !mr-2 text-blue-300 hover:text-white'
|
||||
>
|
||||
AI Roadmaps
|
||||
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
|
||||
></span>
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
Community
|
||||
<NewIndicator />
|
||||
</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'
|
||||
>
|
||||
<Icon icon='search' class='h-3 w-3' />
|
||||
<span class='ml-2'>Search</span>
|
||||
</button>
|
||||
<!--<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'-->
|
||||
<!-->-->
|
||||
<!-- <Icon icon='search' class='h-3 w-3' />-->
|
||||
<!-- <span class='ml-2'>Search</span>-->
|
||||
<!--</button>-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
8
src/components/Navigation/NewIndicator.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
|
||||
></span>
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'></span>
|
||||
</span>
|
||||
</span>
|
||||
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>
|
||||