mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-13 02:01:57 +08:00
Compare commits
412 Commits
fix/activi
...
feat/mobil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eef8c7a0b | ||
|
|
de54e7d8f8 | ||
|
|
5b87465b5a | ||
|
|
03d9e62aaf | ||
|
|
c68823c478 | ||
|
|
3af2a6b6bc | ||
|
|
b88304e238 | ||
|
|
6644d8266e | ||
|
|
d2e3fee99a | ||
|
|
ed40bf51b0 | ||
|
|
f90630c566 | ||
|
|
c9ce2eedb1 | ||
|
|
d5249cc90e | ||
|
|
1bc3464102 | ||
|
|
3c3b0c02a8 | ||
|
|
bfd0343ee9 | ||
|
|
3ec301f2f5 | ||
|
|
5a23d4d326 | ||
|
|
03bf058dd7 | ||
|
|
15b0e33542 | ||
|
|
be6b0128b1 | ||
|
|
b67cb99f41 | ||
|
|
d95c1d66f0 | ||
|
|
4a2130d7d0 | ||
|
|
4d7a0f5d06 | ||
|
|
1cc34e61c4 | ||
|
|
5a19b890a9 | ||
|
|
df17366517 | ||
|
|
5648b14764 | ||
|
|
6076e8e8b5 | ||
|
|
1a351e3aeb | ||
|
|
4a71075340 | ||
|
|
7c60b8cfea | ||
|
|
128582fa40 | ||
|
|
27c3fcdb40 | ||
|
|
6b552b584a | ||
|
|
1b9628f826 | ||
|
|
0aa093b031 | ||
|
|
a16d781681 | ||
|
|
65d7d06d2c | ||
|
|
b7e5d83105 | ||
|
|
92996383cb | ||
|
|
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": 1716803392287
|
||||
}
|
||||
}
|
||||
@@ -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
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
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
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
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
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,83 @@
|
||||
|
||||
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:
|
||||
|
||||
- [Description of link](Link)
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- <p><strong>Adding everything available out there is not the goal!</strong><br />
|
||||
The roadmaps represent the skillset most valuable today, i.e., if you were to enter any of the listed fields today, what would you learn?! There might be things that are of-course being used today but prioritize the things that are most in demand today, e.g., agreed that lots of people are using angular.js today but you wouldn't want to learn that instead of React, Angular, or Vue. Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included.</p>
|
||||
|
||||
The roadmaps represent the skillset most valuable today, i.e., if you were to enter any of the listed fields today, what would you learn? There might be things that are of-course being used today but prioritize the things that are most in demand today, e.g., agreed that lots of people are using angular.js today but you wouldn't want to learn that instead of React, Angular, or Vue. Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included.</p>
|
||||
|
||||
- <p><strong>Do not add things you have not evaluated personally!</strong><br />
|
||||
|
||||
Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included. Have you read this book? Can you give a short article?</p>
|
||||
|
||||
- <p><strong>Create a Single PR for Content Additions</strong></p>
|
||||
|
||||
If you are planning to contribute by adding content to the roadmaps, I recommend you to clone the repository, add content to the [content directory of the roadmap](./src/data/roadmaps/) and create a single PR to make it easier for me to review and merge the PR.
|
||||
- Write meaningful commit messages
|
||||
- Look at the existing issues/pull requests before opening new ones
|
||||
|
||||
- <p><strong>Write meaningful commit messages</strong><br >
|
||||
|
||||
Meaningful commit messages help speed up the review process as well as help other contributors in gaining a good overview of the repositories commit history without having to dive into every commit.
|
||||
|
||||
(See the following guide on how to write good [commit messages](https://www.freecodecamp.org/news/how-to-write-better-git-commit-messages/)).
|
||||
|
||||
</p>
|
||||
- <p><strong>Look at the existing issues/pull requests before opening new ones</strong></p>
|
||||
|
||||
### Good vs Not So Good Contributions
|
||||
|
||||
<strong>Good</strong>
|
||||
|
||||
- New Roadmaps.
|
||||
- Engaging, fresh content links.
|
||||
- Typos and grammatical fixes.
|
||||
- Content copy in topics that do not have any (or minimal copy exists).
|
||||
|
||||
<strong>Not So Good</strong>
|
||||
|
||||
- Adding whitespace that doesn't add to the readability of the content.
|
||||
- Rewriting content in a way that doesn't add any value.
|
||||
- None English content.
|
||||
- PR's that don't follow our style guide, have no description and a default title.
|
||||
14
editor/renderer/index.tsx
Normal file
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
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");
|
||||
}
|
||||
31
package.json
31
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/react": "^3.4.0",
|
||||
"@astrojs/sitemap": "^3.1.5",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@fingerprintjs/fingerprintjs": "^4.3.0",
|
||||
"@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.2",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"astro": "^4.7.0",
|
||||
"astro": "^4.9.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"dom-to-image": "^2.6.0",
|
||||
@@ -40,26 +46,27 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"htm": "^3.1.1",
|
||||
"image-size": "^1.1.1",
|
||||
"jose": "^5.2.4",
|
||||
"jose": "^5.3.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.376.0",
|
||||
"lucide-react": "^0.378.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"nanostores": "^0.10.3",
|
||||
"node-html-parser": "^6.1.13",
|
||||
"npm-check-updates": "^16.14.20",
|
||||
"playwright": "^1.44.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.3.1",
|
||||
"react-calendar-heatmap": "^1.9.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-tooltip": "^5.26.4",
|
||||
"reactflow": "^11.11.2",
|
||||
"reactflow": "^11.11.3",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"satori": "^0.10.13",
|
||||
"satori-html": "^0.3.2",
|
||||
"sharp": "^0.33.3",
|
||||
"sharp": "^0.33.4",
|
||||
"slugify": "^1.6.6",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
@@ -67,20 +74,20 @@
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.43.1",
|
||||
"@playwright/test": "^1.44.0",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/prismjs": "^1.26.4",
|
||||
"@types/react-calendar-heatmap": "^1.6.7",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^6.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"openai": "^4.38.5",
|
||||
"openai": "^4.47.1",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-astro": "^0.13.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"tsx": "^4.7.3"
|
||||
"tsx": "^4.10.5"
|
||||
}
|
||||
}
|
||||
|
||||
9763
pnpm-lock.yaml
generated
9763
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/pdfs/roadmaps/api-design.pdf
Normal file
BIN
public/pdfs/roadmaps/api-design.pdf
Normal file
Binary file not shown.
BIN
public/pdfs/roadmaps/ios.pdf
Normal file
BIN
public/pdfs/roadmaps/ios.pdf
Normal file
Binary file not shown.
BIN
public/roadmaps/api-design.png
Normal file
BIN
public/roadmaps/api-design.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 586 KiB |
BIN
public/roadmaps/ios.png
Normal file
BIN
public/roadmaps/ios.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 672 KiB |
@@ -36,6 +36,7 @@ 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)
|
||||
@@ -58,6 +59,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)
|
||||
@@ -90,6 +92,7 @@ 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)
|
||||
|
||||

|
||||
|
||||
@@ -109,6 +112,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
|
||||
```
|
||||
|
||||
31
scripts/close-issues.sh
Executable file
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
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
|
||||
75
scripts/editor-roadmap-assets.ts
Normal file
75
scripts/editor-roadmap-assets.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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');
|
||||
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
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" etc.)
|
||||
|
||||
(Write me a brief introduction for the topic with regards to "${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
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
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
45
scripts/warm-urls.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Usage: warm-urls.sh <sitemap-url>
|
||||
# Example: warm-urls.sh https://www.example.com/sitemap.xml
|
||||
|
||||
# Check if sitemap url is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo "Please provide sitemap URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get all URLs from sitemap
|
||||
urls=$(curl -s "$1" | grep -o "<loc>[^<]*</loc>" | sed 's#<loc>\(.*\)</loc>#\1#')
|
||||
|
||||
failed_urls=()
|
||||
|
||||
# Warm up URLs
|
||||
for url in $urls; do
|
||||
# Fetch the og:image URL from the meta tags
|
||||
og_image_url=$(curl -s "$url" | grep -o "<meta property=\"og:image\" content=\"[^\"]*\"" | sed 's#<meta property="og:image" content="\([^"]*\)"#\1#')
|
||||
|
||||
# warm the URL
|
||||
echo "Warming up URL: $url"
|
||||
if ! curl -s -I "$url" > /dev/null; then
|
||||
failed_urls+=("$url")
|
||||
fi
|
||||
|
||||
# Warm up the og:image URL
|
||||
if [ -n "$og_image_url" ]; then
|
||||
echo "Warming up OG: $og_image_url"
|
||||
if ! curl -s -I "$og_image_url" > /dev/null; then
|
||||
failed_urls+=("$og_image_url")
|
||||
fi
|
||||
else
|
||||
echo "No og:image found for $url"
|
||||
fi
|
||||
done
|
||||
|
||||
# Print failed URLs
|
||||
if [ ${#failed_urls[@]} -gt 0 ]; then
|
||||
echo "Failed to warm up the following URLs:" >&2
|
||||
for failed_url in "${failed_urls[@]}"; do
|
||||
echo "$failed_url" >&2
|
||||
done
|
||||
fi
|
||||
@@ -18,6 +18,9 @@ export const allowedProfileVisibility = ['public', 'private'] as const;
|
||||
export type AllowedProfileVisibility =
|
||||
(typeof allowedProfileVisibility)[number];
|
||||
|
||||
export const allowedOnboardingStatus = ['done', 'pending', 'ignored'] as const;
|
||||
export type AllowedOnboardingStatus = (typeof allowedOnboardingStatus)[number];
|
||||
|
||||
export interface UserDocument {
|
||||
_id?: string;
|
||||
name: string;
|
||||
@@ -56,6 +59,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,10 +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 { ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||
import { ActivityTopicTitles } from './ActivityTopicTitles.tsx';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
export const allowedActivityActionType = [
|
||||
'in_progress',
|
||||
@@ -29,10 +30,16 @@ export type UserStreamActivity = {
|
||||
|
||||
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] =
|
||||
@@ -48,7 +55,7 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
.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>
|
||||
@@ -78,6 +85,7 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
updatedAt,
|
||||
topicTitles,
|
||||
isCustomResource,
|
||||
resourceSlug,
|
||||
} = activity;
|
||||
|
||||
const resourceUrl =
|
||||
@@ -86,18 +94,28 @@ 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 = topicTitles?.length || 0;
|
||||
|
||||
@@ -111,28 +129,35 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
<li key={_id} className="py-2 text-sm text-gray-600">
|
||||
{actionType === 'in_progress' && (
|
||||
<>
|
||||
Started{' '}
|
||||
<ActivityTopicTitles
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => setSelectedActivity(activity)}
|
||||
/>{' '}
|
||||
in {resourceLinkComponent} {timeAgo}
|
||||
<p className="mb-1">
|
||||
Started {topicCount} topic
|
||||
{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLinkComponent}
|
||||
{timeAgo}
|
||||
</p>
|
||||
<ActivityTopicTitles topicTitles={topicTitles || []} />
|
||||
</>
|
||||
)}
|
||||
{actionType === 'done' && (
|
||||
<>
|
||||
Completed{' '}
|
||||
<ActivityTopicTitles
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => setSelectedActivity(activity)}
|
||||
/>{' '}
|
||||
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>
|
||||
|
||||
@@ -1,41 +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 } = props;
|
||||
const firstThreeTopics = topicTitles?.slice(0, 3);
|
||||
const remainingTopics = topicTitles?.slice(3);
|
||||
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 (
|
||||
<>
|
||||
{firstThreeTopics.map((topicTitle, index) => {
|
||||
return (
|
||||
<span className="font-medium">
|
||||
<>
|
||||
{index > 0 && ', '}
|
||||
{index === firstThreeTopics.length - 1 &&
|
||||
firstThreeTopics.length > 1 &&
|
||||
'and '}
|
||||
{topicTitle}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
{remainingTopics?.length > 0 && (
|
||||
<>
|
||||
and
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={onSelectActivity}
|
||||
>
|
||||
{remainingTopics.length} more topic
|
||||
{remainingTopics.length > 1 ? 's' : ''}
|
||||
</button>
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export function EmptyStream() {
|
||||
return (
|
||||
<div className="rounded-md">
|
||||
<div className="flex flex-col items-center p-7 text-center">
|
||||
<List className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
|
||||
<List className="mb-4 h-[60px] w-[60px] opacity-10 sm:h-[60px] sm:w-[60px]" />
|
||||
|
||||
<h2 className="text-lg font-bold sm:text-xl">No Activities</h2>
|
||||
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getUser } from '../../lib/jwt';
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { ResourceProgressActions } from './ResourceProgressActions';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
@@ -15,10 +16,17 @@ type ResourceProgressType = {
|
||||
showClearButton?: boolean;
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
showActions?: boolean;
|
||||
onResourceClick?: () => void;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const { showClearButton = true, isCustomResource } = props;
|
||||
const {
|
||||
showClearButton = true,
|
||||
isCustomResource,
|
||||
showActions = true,
|
||||
onResourceClick,
|
||||
} = props;
|
||||
|
||||
const userId = getUser()?.id;
|
||||
|
||||
@@ -47,12 +55,23 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = getPercentage(totalMarked, totalCount);
|
||||
|
||||
const Slot = onResourceClick ? 'button' : 'a';
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<a
|
||||
target="_blank"
|
||||
href={url}
|
||||
className="group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 pr-7 text-left text-sm transition-all hover:border-gray-400"
|
||||
<Slot
|
||||
{...(onResourceClick
|
||||
? {
|
||||
onClick: onResourceClick,
|
||||
}
|
||||
: {
|
||||
href: url,
|
||||
target: '_blank',
|
||||
})}
|
||||
className={cn(
|
||||
'group relative flex w-full items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400',
|
||||
showActions ? 'pr-7' : '',
|
||||
)}
|
||||
>
|
||||
<span className="flex-grow truncate">{title}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
@@ -65,18 +84,20 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
></span>
|
||||
</a>
|
||||
</Slot>
|
||||
|
||||
<div className="absolute right-2 top-0 flex h-full items-center">
|
||||
<ResourceProgressActions
|
||||
userId={userId!}
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
onCleared={onCleared}
|
||||
showClearButton={showClearButton}
|
||||
/>
|
||||
</div>
|
||||
{showActions && (
|
||||
<div className="absolute right-2 top-0 flex h-full items-center">
|
||||
<ResourceProgressActions
|
||||
userId={userId!}
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
onCleared={onCleared}
|
||||
showClearButton={showClearButton}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ function handleGuest() {
|
||||
'/team/roadmaps',
|
||||
'/team/new',
|
||||
'/team/members',
|
||||
'/team/member',
|
||||
'/team/settings',
|
||||
];
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ const isBestPracticeReady = !isUpcoming;
|
||||
{
|
||||
isBestPracticeReady && (
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new/choose`}
|
||||
target="_blank"
|
||||
class="inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
aria-label="Suggest Changes"
|
||||
|
||||
@@ -17,6 +17,8 @@ import { ClipboardIcon } from '../ReactIcons/ClipboardIcon.tsx';
|
||||
import { GuideIcon } from '../ReactIcons/GuideIcon.tsx';
|
||||
import { HomeIcon } from '../ReactIcons/HomeIcon.tsx';
|
||||
import { VideoIcon } from '../ReactIcons/VideoIcon.tsx';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
|
||||
|
||||
export type PageType = {
|
||||
id: string;
|
||||
@@ -26,6 +28,7 @@ export type PageType = {
|
||||
icon?: ReactElement;
|
||||
isProtected?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
@@ -190,7 +193,7 @@ export function CommandMenu() {
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 flex h-full justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div className="relative top-0 h-full w-full max-w-lg p-2 sm:top-20 md:h-auto">
|
||||
<div className="relative top-0 h-full w-full max-w-lg p-2 sm:mt-20 md:h-auto">
|
||||
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -245,9 +248,10 @@ export function CommandMenu() {
|
||||
<div className="border-b border-gray-100"></div>
|
||||
)}
|
||||
<a
|
||||
className={`flex w-full items-center rounded p-2 text-sm ${
|
||||
counter === activeCounter ? 'bg-gray-100' : ''
|
||||
}`}
|
||||
className={cn(
|
||||
'flex w-full items-center rounded p-2 text-sm',
|
||||
counter === activeCounter ? 'bg-gray-100' : '',
|
||||
)}
|
||||
onMouseOver={() => setActiveCounter(counter)}
|
||||
href={page.url}
|
||||
>
|
||||
|
||||
@@ -24,6 +24,7 @@ export type TeamResourceConfig = {
|
||||
topics?: number;
|
||||
sharedTeamMemberIds: string[];
|
||||
sharedFriendIds: string[];
|
||||
defaultRoadmapId?: string;
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
@@ -106,6 +107,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Adding roadmap to team`);
|
||||
const renderer = allRoadmaps.find((r) => r.id === roadmapId)?.renderer;
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
@@ -115,6 +117,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
renderer: renderer || 'balsamiq',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -124,6 +127,9 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
setTeamResources(response);
|
||||
if (renderer === 'editor') {
|
||||
setShowSelectRoadmapModal(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -68,7 +68,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
|
||||
<div
|
||||
id={'customized-roadmap'}
|
||||
|
||||
85
src/components/EditorRoadmap/EditorRoadmap.tsx
Normal file
85
src/components/EditorRoadmap/EditorRoadmap.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
EditorRoadmapRenderer,
|
||||
type RoadmapRendererProps,
|
||||
} from './EditorRoadmapRenderer';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { ProgressNudge } from '../FrameRenderer/ProgressNudge';
|
||||
|
||||
type EditorRoadmapProps = {
|
||||
resourceId: string;
|
||||
resourceType?: ResourceType;
|
||||
dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function EditorRoadmap(props: EditorRoadmapProps) {
|
||||
const { resourceId, resourceType = 'roadmap', dimensions } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [roadmapData, setRoadmapData] = useState<
|
||||
Omit<RoadmapRendererProps, 'resourceId'> | undefined
|
||||
>(undefined);
|
||||
|
||||
const loadRoadmapData = async () => {
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpGet<
|
||||
Omit<RoadmapRendererProps, 'resourceId'>
|
||||
>(`/${resourceId}.json`);
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setRoadmapData(response);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRoadmapData().finally();
|
||||
}, [resourceId]);
|
||||
|
||||
if (!roadmapData || isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
'--aspect-ratio': dimensions.width / dimensions.height,
|
||||
} as CSSProperties
|
||||
}
|
||||
className="flex aspect-[var(--aspect-ratio)] w-full 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={
|
||||
{
|
||||
'--aspect-ratio': dimensions.width / dimensions.height,
|
||||
} as CSSProperties
|
||||
}
|
||||
className="flex aspect-[var(--aspect-ratio)] w-full justify-center"
|
||||
>
|
||||
<EditorRoadmapRenderer
|
||||
{...roadmapData}
|
||||
dimensions={dimensions}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
<ProgressNudge resourceId={resourceId} resourceType={resourceType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/EditorRoadmap/EditorRoadmapRenderer.css
Normal file
56
src/components/EditorRoadmap/EditorRoadmapRenderer.css
Normal file
@@ -0,0 +1,56 @@
|
||||
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'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic']:hover > rect {
|
||||
fill: #d6d700;
|
||||
}
|
||||
|
||||
svg > g[data-type='subtopic']:hover > rect {
|
||||
fill: #f3c950;
|
||||
}
|
||||
svg > g[data-type='button']:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
svg g[data-type='link-item']: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 > 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;
|
||||
}
|
||||
192
src/components/EditorRoadmap/EditorRoadmapRenderer.tsx
Normal file
192
src/components/EditorRoadmap/EditorRoadmapRenderer.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
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'];
|
||||
|
||||
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') {
|
||||
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 (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
updateTopicStatus(
|
||||
nodeId,
|
||||
isCurrentStatusLearning ? 'pending' : 'learning',
|
||||
);
|
||||
return;
|
||||
} else if (e.altKey) {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
const detailsPattern = `${slugify(title)}@${nodeId}`;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('roadmap.node.click', {
|
||||
detail: {
|
||||
topicId: detailsPattern,
|
||||
resourceId,
|
||||
resourceType: 'roadmap',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSvgRightClick = useCallback((e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target as SVGElement;
|
||||
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
|
||||
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodeType === 'button') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
const isCurrentStatusDone = targetGroup?.classList.contains('done');
|
||||
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roadmapRef?.current) {
|
||||
return;
|
||||
}
|
||||
roadmapRef?.current?.addEventListener('click', handleSvgClick);
|
||||
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
|
||||
|
||||
return () => {
|
||||
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
|
||||
roadmapRef?.current?.removeEventListener(
|
||||
'contextmenu',
|
||||
handleSvgRightClick,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Renderer
|
||||
ref={roadmapRef}
|
||||
roadmap={{ nodes, edges }}
|
||||
onRendered={() => {
|
||||
roadmapRef.current?.setAttribute('data-renderer', 'editor');
|
||||
renderResourceProgress('roadmap', resourceId).finally();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,47 @@
|
||||
---
|
||||
import type { GuideFileType } from '../lib/guide';
|
||||
import GuideListItem from './GuideListItem.astro';
|
||||
import { QuestionGroupType } from '../lib/question-group';
|
||||
|
||||
export interface Props {
|
||||
heading: string;
|
||||
guides: GuideFileType[];
|
||||
questions: QuestionGroupType[];
|
||||
}
|
||||
|
||||
const { heading, guides } = Astro.props;
|
||||
const { heading, guides, questions = [] } = Astro.props;
|
||||
|
||||
const sortedGuides: (QuestionGroupType | GuideFileType)[] = [
|
||||
...guides,
|
||||
...questions,
|
||||
].sort((a, b) => {
|
||||
const aDate = new Date(a.frontmatter.date);
|
||||
const bDate = new Date(b.frontmatter.date);
|
||||
|
||||
return bDate.getTime() - aDate.getTime();
|
||||
});
|
||||
---
|
||||
|
||||
<div class='container'>
|
||||
<h2 class='text-2xl sm:text-3xl font-bold block'>{heading}</h2>
|
||||
<h2 class='block text-2xl font-bold sm:text-3xl'>{heading}</h2>
|
||||
|
||||
<div class='mt-3 sm:my-5'>
|
||||
{guides.map((guide) => <GuideListItem guide={guide} />)}
|
||||
{sortedGuides.map((guide) => <GuideListItem guide={guide} />)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href='/guides'
|
||||
class='hidden sm:inline transition-colors py-2 px-3 text-xs font-medium rounded-full bg-gradient-to-r from-slate-600 to-black hover:from-blue-600 hover:to-blue-800 text-white'
|
||||
class='hidden rounded-full bg-gradient-to-r from-slate-600 to-black px-3 py-2 text-xs font-medium text-white transition-colors hover:from-blue-600 hover:to-blue-800 sm:inline'
|
||||
>
|
||||
View All Guides →
|
||||
</a>
|
||||
|
||||
<div class='block sm:hidden mt-3'>
|
||||
<div class='mt-3 block sm:hidden'>
|
||||
<a
|
||||
href='/guides'
|
||||
class='text-sm font-regular block p-2 border border-black text-black rounded-md text-center hover:bg-black hover:text-gray-50'
|
||||
class='font-regular block rounded-md border border-black p-2 text-center text-sm text-black hover:bg-black hover:text-gray-50'
|
||||
>
|
||||
View All Guides →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ svg .done rect {
|
||||
fill: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done rect[stroke="rgb(255,229,153)"] {
|
||||
svg .done rect[stroke='rgb(255,229,153)'] {
|
||||
stroke: #cbcbcb !important;
|
||||
}
|
||||
|
||||
@@ -133,10 +133,12 @@ svg .removed path {
|
||||
}
|
||||
}
|
||||
|
||||
#customized-roadmap #resource-svg-wrap g:not([class]),
|
||||
#customized-roadmap #resource-svg-wrap circle,
|
||||
#customized-roadmap #resource-svg-wrap path[stroke='#fff'],
|
||||
#customized-roadmap #resource-svg-wrap g[data-group-id$='-note'] {
|
||||
#customized-roadmap #resource-svg-wrap:not([data-renderer]) g:not([class]),
|
||||
#customized-roadmap #resource-svg-wrap:not([data-renderer]) circle,
|
||||
#customized-roadmap #resource-svg-wrap:not([data-renderer]) path[stroke='#fff'],
|
||||
#customized-roadmap
|
||||
#resource-svg-wrap:not([data-renderer])
|
||||
g[data-group-id$='-note'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useToast } from '../../hooks/use-toast';
|
||||
import { TrashIcon } from '../ReactIcons/TrashIcon';
|
||||
import { AddedUserIcon } from '../ReactIcons/AddedUserIcon';
|
||||
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
|
||||
|
||||
type FriendProgressItemProps = {
|
||||
friend: ListFriendsResponse[0];
|
||||
onShowResourceProgress: (
|
||||
resourceId: string,
|
||||
isCustomResource?: boolean
|
||||
isCustomResource?: boolean,
|
||||
renderer?: AllowedRoadmapRenderer,
|
||||
) => void;
|
||||
onReload: () => void;
|
||||
};
|
||||
@@ -27,7 +29,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
const { response, error } = await httpDelete(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
|
||||
{}
|
||||
{},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -43,7 +45,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
|
||||
{}
|
||||
{},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -92,7 +94,8 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
onClick={() =>
|
||||
onShowResourceProgress(
|
||||
progress.resourceId,
|
||||
progress.isCustomResource
|
||||
progress.isCustomResource,
|
||||
progress?.renderer,
|
||||
)
|
||||
}
|
||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
@@ -160,7 +163,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
deleteFriend(friend.userId, 'Friend removed').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -198,7 +201,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
addFriend(friend.userId, 'Friend request accepted').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -225,7 +228,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
deleteFriend(friend.userId, 'Friend request removed').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -267,7 +270,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
onClick={() => {
|
||||
deleteFriend(
|
||||
friend.userId,
|
||||
'Friend request withdrawn'
|
||||
'Friend request withdrawn',
|
||||
).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
@@ -304,7 +307,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
addFriend(friend.userId, 'Friend request accepted').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="mb-1 block w-full max-w-[150px] rounded-md bg-black py-1.5 text-sm text-white"
|
||||
@@ -316,7 +319,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
onClick={() => {
|
||||
deleteFriend(
|
||||
friend.userId,
|
||||
'Friend request rejected'
|
||||
'Friend request rejected',
|
||||
).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { UserProgressModal } from '../UserProgress/UserProgressModal';
|
||||
import { InviteFriendPopup } from './InviteFriendPopup';
|
||||
import { UserCustomProgressModal } from '../UserProgress/UserCustomProgressModal';
|
||||
import { UserIcon } from 'lucide-react';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
|
||||
|
||||
type FriendResourceProgress = {
|
||||
updatedAt: string;
|
||||
@@ -22,6 +23,7 @@ type FriendResourceProgress = {
|
||||
skipped: number;
|
||||
done: number;
|
||||
total: number;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
};
|
||||
|
||||
export type ListFriendsResponse = {
|
||||
@@ -55,6 +57,7 @@ export function FriendsPage() {
|
||||
resourceId: string;
|
||||
friend: ListFriendsResponse[0];
|
||||
isCustomResource?: boolean;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
}>();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -92,8 +95,8 @@ export function FriendsPage() {
|
||||
(grouping) => grouping.value === selectedGrouping,
|
||||
);
|
||||
|
||||
const filteredFriends = friends.filter(
|
||||
(friend) => selectedGroupingType?.statuses.includes(friend.status),
|
||||
const filteredFriends = friends.filter((friend) =>
|
||||
selectedGroupingType?.statuses.includes(friend.status),
|
||||
);
|
||||
|
||||
const receivedRequests = friends.filter(
|
||||
@@ -124,6 +127,7 @@ export function FriendsPage() {
|
||||
resourceType={'roadmap'}
|
||||
onClose={() => setShowFriendProgress(undefined)}
|
||||
isCustomResource={showFriendProgress?.isCustomResource}
|
||||
renderer={showFriendProgress?.renderer}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -182,11 +186,16 @@ export function FriendsPage() {
|
||||
{filteredFriends.map((friend) => (
|
||||
<FriendProgressItem
|
||||
friend={friend}
|
||||
onShowResourceProgress={(resourceId, isCustomResource) => {
|
||||
onShowResourceProgress={(
|
||||
resourceId,
|
||||
isCustomResource,
|
||||
renderer,
|
||||
) => {
|
||||
setShowFriendProgress({
|
||||
resourceId,
|
||||
friend,
|
||||
isCustomResource,
|
||||
renderer,
|
||||
});
|
||||
}}
|
||||
key={friend.userId}
|
||||
|
||||
@@ -25,20 +25,14 @@ import { Ban, Cog, Download, PenSquare, Save, Wand } from 'lucide-react';
|
||||
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
|
||||
import { httpGet, httpPost } from '../../lib/http.ts';
|
||||
import { pageProgressMessage } from '../../stores/page.ts';
|
||||
import {
|
||||
deleteUrlParam,
|
||||
getUrlParams,
|
||||
setUrlParams,
|
||||
} from '../../lib/browser.ts';
|
||||
import { deleteUrlParam, getUrlParams } from '../../lib/browser.ts';
|
||||
import { downloadGeneratedRoadmapImage } from '../../helper/download-image.ts';
|
||||
import { showLoginPopup } from '../../lib/popup.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
|
||||
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
|
||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||
import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts';
|
||||
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
|
||||
import { useParams } from '../../hooks/use-params.ts';
|
||||
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
|
||||
import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx';
|
||||
|
||||
@@ -294,7 +288,10 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
||||
|
||||
setIsLoading(false);
|
||||
pageProgressMessage.set('');
|
||||
return response.roadmapSlug;
|
||||
return {
|
||||
roadmapId: response.roadmapId,
|
||||
roadmapSlug: response.roadmapSlug,
|
||||
};
|
||||
};
|
||||
|
||||
const downloadGeneratedRoadmapContent = async () => {
|
||||
@@ -686,9 +683,9 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:text-sm"
|
||||
onClick={async () => {
|
||||
const roadmapSlug = await saveAIRoadmap();
|
||||
if (roadmapSlug) {
|
||||
window.location.href = `/r/${roadmapSlug}`;
|
||||
const response = await saveAIRoadmap();
|
||||
if (response?.roadmapSlug) {
|
||||
window.location.href = `/r/${response.roadmapSlug}`;
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
@@ -703,10 +700,10 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
||||
<button
|
||||
className="hidden items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:inline-flex sm:text-sm"
|
||||
onClick={async () => {
|
||||
const roadmapId = await saveAIRoadmap();
|
||||
if (roadmapId) {
|
||||
const response = await saveAIRoadmap();
|
||||
if (response?.roadmapId) {
|
||||
window.open(
|
||||
`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`,
|
||||
`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${response?.roadmapId}`,
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
||||
setIsAuthenticatedUser(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
|
||||
const randomTerms = ['OAuth', 'UI / UX', 'SRE', 'DevRel'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow flex-col items-center px-4 py-6 sm:px-6 md:my-24 lg:my-32">
|
||||
|
||||
@@ -124,7 +124,7 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
|
||||
const openAIKey = getOpenAIKey();
|
||||
|
||||
return (
|
||||
<div className={'relative z-50'}>
|
||||
<div className={'relative z-[90]'}>
|
||||
<div
|
||||
ref={topicRef}
|
||||
tabIndex={0}
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
---
|
||||
import type { GuideFileType } from '../lib/guide';
|
||||
import type { GuideFileType, GuideFrontmatter } from '../lib/guide';
|
||||
import { replaceVariables } from '../lib/markdown';
|
||||
import { QuestionGroupType } from '../lib/question-group';
|
||||
|
||||
export interface Props {
|
||||
guide: GuideFileType;
|
||||
guide: GuideFileType | QuestionGroupType;
|
||||
}
|
||||
|
||||
function isQuestionGroupType(
|
||||
guide: GuideFileType | QuestionGroupType,
|
||||
): guide is QuestionGroupType {
|
||||
return (guide as QuestionGroupType).questions !== undefined;
|
||||
}
|
||||
|
||||
const { guide } = Astro.props;
|
||||
const { frontmatter, id } = guide;
|
||||
|
||||
let pageUrl = '';
|
||||
let guideType = '';
|
||||
|
||||
if (isQuestionGroupType(guide)) {
|
||||
pageUrl = `/questions/${id}`;
|
||||
guideType = 'Questions';
|
||||
} else {
|
||||
const excludedBySlug = (frontmatter as GuideFrontmatter).excludedBySlug;
|
||||
pageUrl = excludedBySlug ? excludedBySlug : `/guides/${id}`;
|
||||
guideType = (frontmatter as GuideFrontmatter).type;
|
||||
}
|
||||
---
|
||||
|
||||
<a
|
||||
class:list={[
|
||||
'text-md group block flex items-center justify-between border-b py-2 text-gray-600 no-underline hover:text-blue-600',
|
||||
]}
|
||||
href={frontmatter.excludedBySlug
|
||||
? frontmatter.excludedBySlug
|
||||
: `/guides/${id}`}
|
||||
href={pageUrl}
|
||||
>
|
||||
<span
|
||||
class='text-sm transition-transform group-hover:translate-x-2 md:text-base'
|
||||
@@ -38,7 +55,7 @@ const { frontmatter, id } = guide;
|
||||
}
|
||||
</span>
|
||||
<span class='hidden text-xs capitalize text-gray-500 sm:block'>
|
||||
{frontmatter.type}
|
||||
{guideType}
|
||||
</span>
|
||||
|
||||
<span class='block text-xs text-gray-400 sm:hidden'> »</span>
|
||||
|
||||
@@ -10,12 +10,12 @@ import { AIAnnouncement } from "../AIAnnouncement";
|
||||
class='container px-5 py-6 pb-14 text-left transition-opacity duration-300 sm:px-0 sm:py-20 sm:text-center'
|
||||
id='hero-text'
|
||||
>
|
||||
<p class='-mt-4 mb-7 sm:-mt-10'>
|
||||
<p class='-mt-4 mb-7 sm:-mt-10 sm:mb-4'>
|
||||
<AIAnnouncement />
|
||||
</p>
|
||||
|
||||
<h1
|
||||
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl'
|
||||
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl sm:leading-tight'
|
||||
>
|
||||
Developer Roadmaps
|
||||
</h1>
|
||||
|
||||
@@ -1,68 +1,173 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, User } from 'lucide-react';
|
||||
import { getUser, isLoggedIn } from '../../lib/jwt';
|
||||
import { AccountDropdownList } from './AccountDropdownList';
|
||||
import { DropdownTeamList } from './DropdownTeamList';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
import { OnboardingModal } from './OnboardingModal.tsx';
|
||||
import { httpGet } from '../../lib/http.ts';
|
||||
import { useToast } from '../../hooks/use-toast.ts';
|
||||
import type { UserDocument } from '../../api/user.ts';
|
||||
import { NotificationIndicator } from './NotificationIndicator.tsx';
|
||||
import { OnboardingNudge } from '../OnboardingNudge.tsx';
|
||||
|
||||
export type OnboardingConfig = Pick<
|
||||
UserDocument,
|
||||
'onboarding' | 'onboardingStatus'
|
||||
>;
|
||||
|
||||
export function AccountDropdown() {
|
||||
const toast = useToast();
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
const [isConfigLoading, setIsConfigLoading] = useState(false);
|
||||
const [isOnboardingModalOpen, setIsOnboardingModalOpen] = useState(false);
|
||||
const [onboardingConfig, setOnboardingConfig] = useState<
|
||||
OnboardingConfig | undefined
|
||||
>(undefined);
|
||||
const currentUser = getUser();
|
||||
|
||||
const shouldShowOnboardingStatus =
|
||||
currentUser?.onboardingStatus === 'pending' ||
|
||||
onboardingConfig?.onboardingStatus === 'pending';
|
||||
|
||||
const loadOnboardingConfig = async () => {
|
||||
if (!isLoggedIn() || !shouldShowOnboardingStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfigLoading(true);
|
||||
const { response, error } = await httpGet<OnboardingConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-onboarding-config`,
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to load onboarding config');
|
||||
}
|
||||
|
||||
setOnboardingConfig(response);
|
||||
};
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setShowDropdown(false);
|
||||
setIsTeamsOpen(false);
|
||||
setIsConfigLoading(true);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn() || !showDropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadOnboardingConfig().finally(() => {
|
||||
setIsConfigLoading(false);
|
||||
});
|
||||
}, [showDropdown]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = () => {
|
||||
loadOnboardingConfig().finally(() => {
|
||||
setIsConfigLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('visibilitychange', loadConfig);
|
||||
return () => {
|
||||
window.removeEventListener('visibilitychange', loadConfig);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onboardingDoneCount = Object.values(
|
||||
onboardingConfig?.onboarding || {},
|
||||
).filter((status) => status !== 'pending').length;
|
||||
const onboardingCount = Object.keys(
|
||||
onboardingConfig?.onboarding || {},
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="relative z-50 animate-fade-in">
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
<>
|
||||
{shouldShowOnboardingStatus && !isOnboardingModalOpen && (
|
||||
<OnboardingNudge
|
||||
onStartOnboarding={() => {
|
||||
loadOnboardingConfig().then(() => {
|
||||
setIsOnboardingModalOpen(true);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
|
||||
onClick={() => {
|
||||
setIsTeamsOpen(false);
|
||||
setShowDropdown(!showDropdown);
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
Account <span className="text-gray-300">/</span> Teams
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
|
||||
</button>
|
||||
<div className="relative z-[90] animate-fade-in">
|
||||
{isOnboardingModalOpen && onboardingConfig && (
|
||||
<OnboardingModal
|
||||
onboardingConfig={onboardingConfig}
|
||||
onClose={() => {
|
||||
setIsOnboardingModalOpen(false);
|
||||
}}
|
||||
onIgnoreTask={(taskId, status) => {
|
||||
loadOnboardingConfig().finally(() => {});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
|
||||
<button
|
||||
className="relative flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
|
||||
onClick={() => {
|
||||
setIsTeamsOpen(false);
|
||||
setShowDropdown(!showDropdown);
|
||||
}}
|
||||
>
|
||||
{isTeamsOpen ? (
|
||||
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
|
||||
) : (
|
||||
<AccountDropdownList
|
||||
onCreateRoadmap={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
setIsTeamsOpen={setIsTeamsOpen}
|
||||
/>
|
||||
<span className="inline-flex items-center">
|
||||
Account <span className="text-gray-300">/</span> Teams
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
|
||||
{shouldShowOnboardingStatus && !showDropdown && (
|
||||
<NotificationIndicator />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
|
||||
>
|
||||
{isTeamsOpen ? (
|
||||
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
|
||||
) : (
|
||||
<AccountDropdownList
|
||||
onCreateRoadmap={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
setIsTeamsOpen={setIsTeamsOpen}
|
||||
onOnboardingClick={() => {
|
||||
setIsOnboardingModalOpen(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
shouldShowOnboardingStatus={shouldShowOnboardingStatus}
|
||||
isConfigLoading={isConfigLoading}
|
||||
onboardingConfigCount={onboardingCount}
|
||||
doneConfigCount={onboardingDoneCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,21 +6,67 @@ import {
|
||||
SquareUserRound,
|
||||
User2,
|
||||
Users2,
|
||||
Handshake,
|
||||
} from 'lucide-react';
|
||||
import { logout } from './navigation';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { NotificationIndicator } from './NotificationIndicator.tsx';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon.tsx';
|
||||
|
||||
type AccountDropdownListProps = {
|
||||
onCreateRoadmap: () => void;
|
||||
setIsTeamsOpen: (isOpen: boolean) => void;
|
||||
onOnboardingClick: () => void;
|
||||
isConfigLoading: boolean;
|
||||
shouldShowOnboardingStatus?: boolean;
|
||||
onboardingConfigCount: number;
|
||||
doneConfigCount: number;
|
||||
};
|
||||
|
||||
export function AccountDropdownList(props: AccountDropdownListProps) {
|
||||
const { setIsTeamsOpen, onCreateRoadmap } = props;
|
||||
const {
|
||||
setIsTeamsOpen,
|
||||
onCreateRoadmap,
|
||||
onOnboardingClick,
|
||||
isConfigLoading = true,
|
||||
shouldShowOnboardingStatus = false,
|
||||
onboardingConfigCount,
|
||||
doneConfigCount,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{shouldShowOnboardingStatus && (
|
||||
<li className="mb-1 px-1">
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center rounded py-1 pl-3 pr-2 text-sm font-medium text-slate-100 hover:opacity-80',
|
||||
isConfigLoading
|
||||
? 'striped-loader-darker flex border-slate-800 opacity-70'
|
||||
: 'border-slate-600 bg-slate-700',
|
||||
)}
|
||||
onClick={onOnboardingClick}
|
||||
disabled={isConfigLoading}
|
||||
>
|
||||
<NotificationIndicator className="-left-0.5 -top-0.5" />
|
||||
|
||||
{isConfigLoading ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
<Handshake className="mr-2 h-4 w-4 text-slate-400 group-hover:text-white" />
|
||||
<span>Onboarding</span>
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs text-slate-400">
|
||||
{doneConfigCount} of {onboardingConfigCount}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
<li className="px-1">
|
||||
<a
|
||||
href="/account"
|
||||
|
||||
@@ -17,10 +17,10 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
</a>
|
||||
|
||||
<a
|
||||
href='/ai'
|
||||
href='/teams'
|
||||
class='group inline sm:hidden relative !mr-2 text-blue-300 hover:text-white'
|
||||
>
|
||||
AI Roadmaps
|
||||
Teams
|
||||
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
@@ -39,13 +39,11 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
<a href='/get-started' class='text-gray-400 hover:text-white'>
|
||||
Start Here
|
||||
</a>
|
||||
<a href='/teams' class='text-gray-400 hover:text-white'> Teams</a>
|
||||
<a
|
||||
href='/ai'
|
||||
<a
|
||||
href='/teams'
|
||||
class='group relative !mr-2 text-blue-300 hover:text-white'
|
||||
>
|
||||
AI Roadmaps
|
||||
|
||||
Teams
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
@@ -56,6 +54,8 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href='/ai' class='text-gray-400 hover:text-white'> AI Roadmaps</a>
|
||||
<button
|
||||
data-command-menu
|
||||
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1.5 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 md:flex'
|
||||
|
||||
20
src/components/Navigation/NotificationIndicator.tsx
Normal file
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
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
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>
|
||||
);
|
||||
}
|
||||
@@ -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,7 @@ 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';
|
||||
|
||||
export type PageSponsorType = {
|
||||
company: string;
|
||||
@@ -50,6 +51,7 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
|
||||
{
|
||||
href: window.location.pathname,
|
||||
mobile: isMobile() ? 'true' : 'false',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -75,9 +77,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() ? true : false,
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
|
||||
@@ -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
154
src/components/Questions/QuestionGuide.astro
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
import {
|
||||
getGuideTableOfContent,
|
||||
type GuideFileType,
|
||||
HeadingGroupType,
|
||||
} from '../../lib/guide';
|
||||
import MarkdownFile from '../MarkdownFile.astro';
|
||||
import { TableOfContent } from '../TableOfContent/TableOfContent';
|
||||
import { markdownToHtml, replaceVariables } from '../../lib/markdown';
|
||||
import { QuestionGroupType } from '../../lib/question-group';
|
||||
import { QuestionsList } from './QuestionsList';
|
||||
|
||||
interface Props {
|
||||
questionGroup: QuestionGroupType;
|
||||
}
|
||||
|
||||
const { questionGroup } = Astro.props;
|
||||
|
||||
const allHeadings = questionGroup.getHeadings();
|
||||
const tableOfContent: HeadingGroupType[] = [
|
||||
...getGuideTableOfContent(allHeadings),
|
||||
{
|
||||
depth: 2,
|
||||
title: 'Test with Flashcards',
|
||||
children: [],
|
||||
slug: 'test-with-flashcards',
|
||||
text: 'Test yourself with Flashcards',
|
||||
},
|
||||
{
|
||||
depth: 2,
|
||||
title: 'Questions List',
|
||||
children: [
|
||||
{
|
||||
depth: 2,
|
||||
title: 'Beginner Level',
|
||||
children: [],
|
||||
slug: 'beginner-level',
|
||||
text: 'Beginner Level',
|
||||
} as HeadingGroupType,
|
||||
{
|
||||
depth: 2,
|
||||
title: 'Intermediate Level',
|
||||
children: [],
|
||||
slug: 'intermediate-level',
|
||||
text: 'Intermediate Level',
|
||||
} as HeadingGroupType,
|
||||
{
|
||||
depth: 2,
|
||||
title: 'Advanced Level',
|
||||
children: [],
|
||||
slug: 'advanced-level',
|
||||
text: 'Advanced Level',
|
||||
} as HeadingGroupType,
|
||||
],
|
||||
slug: 'questions-list',
|
||||
text: 'Questions List',
|
||||
},
|
||||
];
|
||||
|
||||
const showTableOfContent = tableOfContent.length > 0;
|
||||
const { frontmatter: guideFrontmatter, author } = questionGroup;
|
||||
---
|
||||
|
||||
<article class='lg:grid lg:max-w-full lg:grid-cols-[1fr_minmax(0,700px)_1fr]'>
|
||||
{
|
||||
showTableOfContent && (
|
||||
<div class='bg-gradient-to-r from-gray-50 py-0 lg:col-start-3 lg:col-end-4 lg:row-start-1'>
|
||||
<TableOfContent toc={tableOfContent} client:load />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'col-start-2 col-end-3 row-start-1 mx-auto max-w-[700px] py-5 sm:py-10',
|
||||
{
|
||||
'lg:border-r': showTableOfContent,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MarkdownFile>
|
||||
<h1 class='mb-3 text-balance text-4xl font-bold'>
|
||||
{replaceVariables(guideFrontmatter.title)}
|
||||
</h1>
|
||||
{
|
||||
author && (
|
||||
<p class='my-0 flex items-center justify-start text-sm text-gray-400'>
|
||||
<a
|
||||
href={`/authors/${author?.id}`}
|
||||
class='inline-flex items-center font-medium underline-offset-2 hover:text-gray-600 hover:underline'
|
||||
>
|
||||
<img
|
||||
alt={author.frontmatter.name}
|
||||
src={author.frontmatter.imageUrl}
|
||||
class='mb-0 mr-2 inline h-5 w-5 rounded-full'
|
||||
/>
|
||||
{author.frontmatter.name}
|
||||
</a>
|
||||
<span class='mx-2 hidden sm:inline'>·</span>
|
||||
<a
|
||||
class='hidden underline-offset-2 hover:text-gray-600 sm:inline'
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/question-groups/${questionGroup.id}`}
|
||||
target='_blank'
|
||||
>
|
||||
Improve this Guide
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<questionGroup.Content />
|
||||
|
||||
<h2 id='test-with-flashcards'>Test yourself with Flashcards</h2>
|
||||
<p>
|
||||
You can either use these flashcards or jump to the questions list
|
||||
section below to see them in a list format.
|
||||
</p>
|
||||
<div class='mx-0 sm:-mb-32'>
|
||||
<QuestionsList
|
||||
groupId={questionGroup.id}
|
||||
questions={questionGroup.questions}
|
||||
client:load
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 id='questions-list'>Questions List</h2>
|
||||
<p>
|
||||
If you prefer to see the questions in a list format, you can find them
|
||||
below.
|
||||
</p>
|
||||
|
||||
{
|
||||
['beginner', 'intermediate', 'advanced'].map((questionLevel) => (
|
||||
<div class='mb-5'>
|
||||
<h3 id={`${questionLevel}-level`} class='mb-0 capitalize'>
|
||||
{questionLevel} Level
|
||||
</h3>
|
||||
{questionGroup.questions
|
||||
.filter((q) => {
|
||||
return q.topics
|
||||
.map((t) => t.toLowerCase())
|
||||
.includes(questionLevel);
|
||||
})
|
||||
.map((q) => (
|
||||
<div class='mb-5'>
|
||||
<h4>{q.question}</h4>
|
||||
<div set:html={markdownToHtml(q.answer, false)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</MarkdownFile>
|
||||
</div>
|
||||
</article>
|
||||
@@ -24,14 +24,14 @@ type QuestionsListProps = {
|
||||
};
|
||||
|
||||
export function QuestionsList(props: QuestionsListProps) {
|
||||
const { questions: unshuffledQuestions, groupId } = props;
|
||||
const { questions: defaultQuestions, groupId } = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [questions, setQuestions] = useState(defaultQuestions);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [questions, setQuestions] = useState<QuestionType[]>();
|
||||
const [pendingQuestions, setPendingQuestions] = useState<QuestionType[]>([]);
|
||||
const [currQuestionIndex, setCurrQuestionIndex] = useState(0);
|
||||
|
||||
const [userProgress, setUserProgress] = useState<UserQuestionProgress>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -57,7 +57,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
return response;
|
||||
}
|
||||
|
||||
async function loadQuestions() {
|
||||
async function prepareProgress() {
|
||||
const userProgress = await fetchUserProgress();
|
||||
setUserProgress(userProgress);
|
||||
|
||||
@@ -65,7 +65,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
const didNotKnowQuestions = userProgress?.dontKnow || [];
|
||||
const skipQuestions = userProgress?.skip || [];
|
||||
|
||||
const pendingQuestions = unshuffledQuestions.filter((question) => {
|
||||
const pendingQuestionIndex = questions.findIndex((question) => {
|
||||
return (
|
||||
!knownQuestions.includes(question.id) &&
|
||||
!didNotKnowQuestions.includes(question.id) &&
|
||||
@@ -73,30 +73,21 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
);
|
||||
});
|
||||
|
||||
// Shuffle and set pending questions
|
||||
setPendingQuestions(pendingQuestions.sort(() => Math.random() - 0.5));
|
||||
setQuestions(unshuffledQuestions);
|
||||
|
||||
setCurrQuestionIndex(pendingQuestionIndex);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function resetProgress(type: QuestionProgressType | 'reset' = 'reset') {
|
||||
async function resetProgress() {
|
||||
let knownQuestions = userProgress?.know || [];
|
||||
let didNotKnowQuestions = userProgress?.dontKnow || [];
|
||||
let skipQuestions = userProgress?.skip || [];
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
if (type === 'know') {
|
||||
knownQuestions = [];
|
||||
} else if (type === 'dontKnow') {
|
||||
didNotKnowQuestions = [];
|
||||
} else if (type === 'skip') {
|
||||
skipQuestions = [];
|
||||
} else if (type === 'reset') {
|
||||
knownQuestions = [];
|
||||
didNotKnowQuestions = [];
|
||||
skipQuestions = [];
|
||||
}
|
||||
setQuestions(defaultQuestions);
|
||||
|
||||
knownQuestions = [];
|
||||
didNotKnowQuestions = [];
|
||||
skipQuestions = [];
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -105,7 +96,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-reset-question-progress/${groupId}`,
|
||||
{
|
||||
status: type,
|
||||
status: 'reset',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -119,21 +110,13 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
skipQuestions = response?.skip || [];
|
||||
}
|
||||
|
||||
const pendingQuestions = unshuffledQuestions.filter((question) => {
|
||||
return (
|
||||
!knownQuestions.includes(question.id) &&
|
||||
!didNotKnowQuestions.includes(question.id) &&
|
||||
!skipQuestions.includes(question.id)
|
||||
);
|
||||
});
|
||||
|
||||
setCurrQuestionIndex(0);
|
||||
setUserProgress({
|
||||
know: knownQuestions,
|
||||
dontKnow: didNotKnowQuestions,
|
||||
skip: skipQuestions,
|
||||
});
|
||||
|
||||
setPendingQuestions(pendingQuestions.sort(() => Math.random() - 0.5));
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -172,30 +155,29 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
newProgress = response;
|
||||
}
|
||||
|
||||
const updatedQuestionList = pendingQuestions.filter(
|
||||
(q) => q.id !== questionId,
|
||||
);
|
||||
const nextQuestionIndex = currQuestionIndex + 1;
|
||||
|
||||
setUserProgress(newProgress);
|
||||
setPendingQuestions(updatedQuestionList);
|
||||
setIsLoading(false);
|
||||
|
||||
if (updatedQuestionList.length === 0) {
|
||||
if (!nextQuestionIndex || !questions[nextQuestionIndex]) {
|
||||
setShowConfetti(true);
|
||||
}
|
||||
|
||||
setCurrQuestionIndex(nextQuestionIndex);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions().then(() => null);
|
||||
}, [unshuffledQuestions]);
|
||||
prepareProgress().then(() => null);
|
||||
}, [questions]);
|
||||
|
||||
const knowCount = userProgress?.know.length || 0;
|
||||
const dontKnowCount = userProgress?.dontKnow.length || 0;
|
||||
const skipCount = userProgress?.skip.length || 0;
|
||||
const hasProgress = knowCount > 0 || dontKnowCount > 0 || skipCount > 0;
|
||||
|
||||
const currQuestion = pendingQuestions[0];
|
||||
const hasFinished = !isLoading && hasProgress && !currQuestion;
|
||||
const currQuestion = questions[currQuestionIndex];
|
||||
const hasFinished = !isLoading && hasProgress && currQuestionIndex === -1;
|
||||
|
||||
return (
|
||||
<div className="mb-0 gap-3 text-center sm:mb-40">
|
||||
@@ -203,11 +185,37 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
knowCount={knowCount}
|
||||
didNotKnowCount={dontKnowCount}
|
||||
skippedCount={skipCount}
|
||||
totalCount={unshuffledQuestions?.length || questions?.length}
|
||||
totalCount={questions?.length}
|
||||
isLoading={isLoading}
|
||||
showLoginAlert={!isLoggedIn() && hasProgress}
|
||||
onResetClick={() => {
|
||||
resetProgress('reset').finally(() => null);
|
||||
resetProgress().finally(() => null);
|
||||
}}
|
||||
onNextClick={() => {
|
||||
if (
|
||||
currQuestionIndex !== -1 &&
|
||||
currQuestionIndex < questions.length - 1
|
||||
) {
|
||||
updateQuestionStatus('skip', currQuestion.id).finally(() => null);
|
||||
}
|
||||
}}
|
||||
onPrevClick={() => {
|
||||
if (currQuestionIndex > 0) {
|
||||
const prevQuestion = questions[currQuestionIndex - 1];
|
||||
// remove last question from the progress of the user
|
||||
const tempUserProgress = {
|
||||
know:
|
||||
userProgress?.know.filter((id) => id !== prevQuestion.id) || [],
|
||||
dontKnow:
|
||||
userProgress?.dontKnow.filter((id) => id !== prevQuestion.id) ||
|
||||
[],
|
||||
skip:
|
||||
userProgress?.skip.filter((id) => id !== prevQuestion.id) || [],
|
||||
};
|
||||
|
||||
setUserProgress(tempUserProgress);
|
||||
setCurrQuestionIndex(currQuestionIndex - 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -227,12 +235,12 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
>
|
||||
{hasFinished && (
|
||||
<QuestionFinished
|
||||
totalCount={unshuffledQuestions?.length || questions?.length || 0}
|
||||
totalCount={questions?.length || 0}
|
||||
knowCount={knowCount}
|
||||
didNotKnowCount={dontKnowCount}
|
||||
skippedCount={skipCount}
|
||||
onReset={(type: QuestionProgressType | 'reset') => {
|
||||
resetProgress(type).finally(() => null);
|
||||
onReset={() => {
|
||||
resetProgress().finally(() => null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { CheckCircle, RotateCcw, SkipForward, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RotateCcw,
|
||||
SkipForward,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
type QuestionsProgressProps = {
|
||||
@@ -9,6 +16,8 @@ type QuestionsProgressProps = {
|
||||
totalCount?: number;
|
||||
skippedCount?: number;
|
||||
onResetClick?: () => void;
|
||||
onPrevClick?: () => void;
|
||||
onNextClick?: () => void;
|
||||
};
|
||||
|
||||
export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
@@ -20,6 +29,8 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
totalCount = 0,
|
||||
skippedCount = 0,
|
||||
onResetClick = () => null,
|
||||
onPrevClick = () => null,
|
||||
onNextClick = () => null,
|
||||
} = props;
|
||||
|
||||
const totalSolved = knowCount + didNotKnowCount + skippedCount;
|
||||
@@ -36,8 +47,22 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-3 text-sm">
|
||||
{totalSolved} / {totalCount}
|
||||
<span className="ml-3 flex items-center text-sm">
|
||||
<button
|
||||
onClick={onPrevClick}
|
||||
className="text-zinc-400 hover:text-black"
|
||||
>
|
||||
<ChevronLeft className="h-4" strokeWidth={3} />
|
||||
</button>
|
||||
<span className="block min-w-[41px] text-center">
|
||||
<span className="tabular-nums">{totalSolved}</span> / {totalCount}
|
||||
</span>
|
||||
<button
|
||||
onClick={onNextClick}
|
||||
className="text-zinc-400 hover:text-black"
|
||||
>
|
||||
<ChevronRight className="h-4" strokeWidth={3} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -46,9 +71,7 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
<CheckCircle className="mr-1 h-4" />
|
||||
<span>Knew</span>
|
||||
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
|
||||
<span className="tabular-nums">{knowCount}</span>{' '}
|
||||
<span className="hidden lg:inline">Questions</span>
|
||||
<span className="inline sm:hidden">Questions</span>
|
||||
<span className="tabular-nums">{knowCount}</span> Items
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -56,9 +79,7 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
<Sparkles className="mr-1 h-4" />
|
||||
<span>Learnt</span>
|
||||
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
|
||||
<span className="tabular-nums">{didNotKnowCount}</span>{' '}
|
||||
<span className="hidden lg:inline">Questions</span>
|
||||
<span className="inline sm:hidden">Questions</span>
|
||||
<span className="tabular-nums">{didNotKnowCount}</span> Items
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -66,9 +87,7 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
<SkipForward className="mr-1 h-4" />
|
||||
<span>Skipped</span>
|
||||
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
|
||||
<span className="tabular-nums">{skippedCount}</span>{' '}
|
||||
<span className="hidden lg:inline">Questions</span>
|
||||
<span className="inline sm:hidden">Questions</span>
|
||||
<span className="tabular-nums">{skippedCount}</span> Items
|
||||
</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { getQuestionGroupsByIds } from '../lib/question-group';
|
||||
import { getRoadmapsByIds, RoadmapFrontmatter } from '../lib/roadmap';
|
||||
import { getRoadmapsByIds, type RoadmapFrontmatter } from '../lib/roadmap';
|
||||
import { Map, Clipboard } from 'lucide-react';
|
||||
|
||||
export interface Props {
|
||||
@@ -59,14 +59,19 @@ const relatedQuestionDetails = await getQuestionGroupsByIds(relatedQuestions);
|
||||
|
||||
{
|
||||
relatedRoadmaps.length && (
|
||||
<div class:list={['border-t bg-gray-100', {
|
||||
'mt-8': !relatedQuestionDetails.length
|
||||
}]}>
|
||||
<div
|
||||
class:list={[
|
||||
'border-t bg-gray-100',
|
||||
{
|
||||
'mt-8': !relatedQuestionDetails.length,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div class='container'>
|
||||
<div class='relative -top-5 flex justify-between'>
|
||||
<span class='text-md flex items-center rounded-md border bg-white px-3 py-1 font-medium'>
|
||||
<Map className='text-black mr-1.5' size='17px' />
|
||||
Related Roadmaps
|
||||
Related <span class='hidden sm:inline'>Roadmaps</span>
|
||||
</span>
|
||||
<a
|
||||
href='/roadmaps'
|
||||
|
||||
@@ -4,10 +4,11 @@ import { CopyIcon } from 'lucide-react';
|
||||
type EditorProps = {
|
||||
title: string;
|
||||
text: string;
|
||||
onCopy?: () => void;
|
||||
};
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
const { text, title } = props;
|
||||
const { text, title, onCopy } = props;
|
||||
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
|
||||
@@ -17,7 +18,13 @@ export function Editor(props: EditorProps) {
|
||||
<span className="text-xs uppercase leading-none text-gray-400">
|
||||
{title}
|
||||
</span>
|
||||
<button className="flex items-center" onClick={() => copyText(text)}>
|
||||
<button
|
||||
className="flex items-center"
|
||||
onClick={() => {
|
||||
copyText(text);
|
||||
onCopy?.();
|
||||
}}
|
||||
>
|
||||
{isCopied && (
|
||||
<span className="mr-1 text-xs leading-none text-gray-700">
|
||||
Copied!
|
||||
@@ -33,6 +40,7 @@ export function Editor(props: EditorProps) {
|
||||
onClick={(e: any) => {
|
||||
e.target.select();
|
||||
copyText(e.target.value);
|
||||
onCopy?.();
|
||||
}}
|
||||
value={text}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { SelectionButton } from './SelectionButton';
|
||||
import { StepCounter } from './StepCounter';
|
||||
import { Editor } from './Editor';
|
||||
import { CopyIcon } from 'lucide-react';
|
||||
import { httpPatch } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type StepLabelProps = {
|
||||
label: string;
|
||||
@@ -24,11 +26,28 @@ function StepLabel(props: StepLabelProps) {
|
||||
}
|
||||
|
||||
export function RoadCardPage() {
|
||||
const user = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
const [roadmaps, setRoadmaps] = useState<string[]>([]);
|
||||
const [version, setVersion] = useState<'tall' | 'wide'>('tall');
|
||||
const [variant, setVariant] = useState<'dark' | 'light'>('dark');
|
||||
const user = useAuth();
|
||||
|
||||
const markRoadCardDone = async () => {
|
||||
const { error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-onboarding-config`,
|
||||
{
|
||||
id: 'roadCard',
|
||||
status: 'done',
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
@@ -131,20 +150,24 @@ export function RoadCardPage() {
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
className="flex items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
downloadImage({
|
||||
url: badgeUrl.toString(),
|
||||
name: 'road-card',
|
||||
scale: 4,
|
||||
})
|
||||
}
|
||||
});
|
||||
markRoadCardDone();
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
disabled={isCopied}
|
||||
className="flex cursor-pointer items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50"
|
||||
onClick={() => copyText(badgeUrl.toString())}
|
||||
onClick={() => {
|
||||
copyText(badgeUrl.toString());
|
||||
markRoadCardDone();
|
||||
}}
|
||||
>
|
||||
<CopyIcon size={16} className="mr-1 inline-block h-4 w-4" />
|
||||
|
||||
@@ -156,11 +179,13 @@ export function RoadCardPage() {
|
||||
<Editor
|
||||
title={'HTML'}
|
||||
text={`<a href="https://roadmap.sh"><img src="${badgeUrl}" alt="roadmap.sh"/></a>`.trim()}
|
||||
onCopy={() => markRoadCardDone()}
|
||||
/>
|
||||
|
||||
<Editor
|
||||
title={'Markdown'}
|
||||
text={`[](https://roadmap.sh)`.trim()}
|
||||
onCopy={() => markRoadCardDone()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ const hasTnsBanner = !!tnsBannerLink;
|
||||
{
|
||||
isRoadmapReady && (
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new/choose`}
|
||||
target='_blank'
|
||||
class='inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Suggest Changes'
|
||||
|
||||
@@ -23,7 +23,7 @@ const hasTnsBanner = !!tnsBannerLink;
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 bg-white',
|
||||
'mb-0 mt-4 rounded-md border-0 bg-white sm:mt-7 sm:border',
|
||||
...(hasTnsBanner
|
||||
? [
|
||||
{
|
||||
@@ -42,7 +42,7 @@ const hasTnsBanner = !!tnsBannerLink;
|
||||
<ResourceProgressStats
|
||||
resourceId={roadmapId}
|
||||
resourceType='roadmap'
|
||||
hasSecondaryBanner={hasTitleQuestion}
|
||||
hasSecondaryBanner={Boolean(hasTitleQuestion)}
|
||||
/>
|
||||
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
|
||||
return (
|
||||
<div className="relative hidden border-t text-sm font-medium sm:block">
|
||||
{isAnswerVisible && (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50"></div>
|
||||
<div className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50"></div>
|
||||
)}
|
||||
<h2
|
||||
className="z-50 flex cursor-pointer items-center px-2 py-2.5 text-base font-medium"
|
||||
@@ -41,7 +41,7 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={`absolute left-0 right-0 top-0 z-50 mt-0 rounded-md border bg-white ${
|
||||
className={`absolute left-0 right-0 top-0 z-[100] mt-0 rounded-md border bg-white ${
|
||||
isAnswerVisible ? 'block' : 'hidden'
|
||||
}`}
|
||||
ref={ref}
|
||||
|
||||
@@ -77,6 +77,12 @@ const groups: GroupType[] = [
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development', 'Absolute Beginners'],
|
||||
},
|
||||
{
|
||||
title: 'API Design',
|
||||
link: '/api-design',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'QA',
|
||||
link: '/qa',
|
||||
@@ -226,15 +232,20 @@ const groups: GroupType[] = [
|
||||
link: '/android',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'iOS',
|
||||
link: '/ios',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'React Native',
|
||||
link: '/react-native',
|
||||
type: 'role',
|
||||
type: 'skill',
|
||||
},
|
||||
{
|
||||
title: 'Flutter',
|
||||
link: '/flutter',
|
||||
type: 'role',
|
||||
type: 'skill',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isTransferringToTeam, setIsTransferringToTeam] = useState(false);
|
||||
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
|
||||
const [friends, setFriends] = useState<ListFriendsResponse>([]);
|
||||
const [teams, setTeams] = useState<UserTeamItem[]>([]);
|
||||
@@ -71,13 +72,12 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
|
||||
const canTransferRoadmap = visibility === 'team' && !teamId;
|
||||
let isUpdateDisabled = false;
|
||||
// Disable update button if there are no friends to share with
|
||||
if (visibility === 'friends' && sharedFriendIds.length === 0) {
|
||||
isUpdateDisabled = true;
|
||||
// Disable update button if there are no team to transfer
|
||||
} else if (canTransferRoadmap && !selectedTeamId) {
|
||||
} else if (isTransferringToTeam && !selectedTeamId) {
|
||||
isUpdateDisabled = true;
|
||||
// Disable update button if there are no members to share with
|
||||
} else if (
|
||||
@@ -198,6 +198,8 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
</div>
|
||||
|
||||
<ShareOptionTabs
|
||||
isTransferringToTeam={isTransferringToTeam}
|
||||
setIsTransferringToTeam={setIsTransferringToTeam}
|
||||
visibility={visibility}
|
||||
setVisibility={setVisibility}
|
||||
teamId={teamId}
|
||||
@@ -226,48 +228,52 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex grow flex-col">
|
||||
{visibility === 'public' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Globe2 className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Anyone with the link can access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{visibility === 'me' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Lock className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Only you will be able to access.
|
||||
</p>
|
||||
</div>
|
||||
{!isTransferringToTeam && (
|
||||
<>
|
||||
{visibility === 'public' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Globe2 className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Anyone with the link can access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{visibility === 'me' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Lock className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Only you will be able to access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* For Personal Roadmap */}
|
||||
{visibility === 'friends' && (
|
||||
<ShareFriendList
|
||||
friends={friends}
|
||||
setFriends={setFriends}
|
||||
sharedFriendIds={sharedFriendIds}
|
||||
setSharedFriendIds={setSharedFriendIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* For Team Roadmap */}
|
||||
{visibility === 'team' && teamId && (
|
||||
<ShareTeamMemberList
|
||||
teamId={teamId}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
membersCache={membersCache}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* For Personal Roadmap */}
|
||||
{visibility === 'friends' && (
|
||||
<ShareFriendList
|
||||
friends={friends}
|
||||
setFriends={setFriends}
|
||||
sharedFriendIds={sharedFriendIds}
|
||||
setSharedFriendIds={setSharedFriendIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* For Team Roadmap */}
|
||||
{visibility === 'team' && teamId && (
|
||||
<ShareTeamMemberList
|
||||
teamId={teamId}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
membersCache={membersCache}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canTransferRoadmap && (
|
||||
{isTransferringToTeam && (
|
||||
<>
|
||||
<TransferToTeamList
|
||||
currentTeamId={teamId}
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
selectedTeamId={selectedTeamId}
|
||||
@@ -319,7 +325,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
Close
|
||||
</button>
|
||||
|
||||
{canTransferRoadmap && (
|
||||
{isTransferringToTeam && (
|
||||
<UpdateAction
|
||||
disabled={
|
||||
isUpdateDisabled || isLoading || sharedTeamMemberIds.length === 0
|
||||
@@ -335,7 +341,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
</UpdateAction>
|
||||
)}
|
||||
|
||||
{!canTransferRoadmap && (
|
||||
{!isTransferringToTeam && (
|
||||
<UpdateAction
|
||||
disabled={isUpdateDisabled || isLoading}
|
||||
onClick={() => {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { $teamList } from '../../stores/team.ts';
|
||||
import { useStore } from '@nanostores/react';
|
||||
|
||||
export const allowedVisibilityLabels: {
|
||||
id: AllowedRoadmapVisibility;
|
||||
@@ -44,15 +46,29 @@ export const allowedVisibilityLabels: {
|
||||
type ShareOptionTabsProps = {
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
setVisibility: (visibility: AllowedRoadmapVisibility) => void;
|
||||
|
||||
isTransferringToTeam: boolean;
|
||||
setIsTransferringToTeam: (isTransferringToTeam: boolean) => void;
|
||||
|
||||
teamId?: string;
|
||||
|
||||
onChange: (visibility: AllowedRoadmapVisibility) => void;
|
||||
};
|
||||
|
||||
export function ShareOptionTabs(props: ShareOptionTabsProps) {
|
||||
const { visibility, setVisibility, teamId, onChange } = props;
|
||||
const {
|
||||
isTransferringToTeam,
|
||||
setIsTransferringToTeam,
|
||||
visibility,
|
||||
setVisibility,
|
||||
teamId,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
const handleClick = (visibility: AllowedRoadmapVisibility) => {
|
||||
const teamList = useStore($teamList);
|
||||
|
||||
const handleTabClick = (visibility: AllowedRoadmapVisibility) => {
|
||||
setIsTransferringToTeam(false);
|
||||
setVisibility(visibility);
|
||||
onChange(visibility);
|
||||
};
|
||||
@@ -63,11 +79,9 @@ export function ShareOptionTabs(props: ShareOptionTabsProps) {
|
||||
{allowedVisibilityLabels.map((v) => {
|
||||
if (v.id === 'friends' && teamId) {
|
||||
return null;
|
||||
} else if (v.id === 'team' && !teamId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive = v.id === visibility;
|
||||
const isActive = !isTransferringToTeam && v.id === visibility;
|
||||
return (
|
||||
<li key={v.id}>
|
||||
<OptionTab
|
||||
@@ -75,21 +89,21 @@ export function ShareOptionTabs(props: ShareOptionTabsProps) {
|
||||
isActive={isActive}
|
||||
icon={v.icon}
|
||||
onClick={() => {
|
||||
handleClick(v.id);
|
||||
handleTabClick(v.id);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{!teamId && (
|
||||
{(!teamId || teamList.length > 1) && (
|
||||
<div className="grow">
|
||||
<OptionTab
|
||||
label="Transfer to team"
|
||||
icon={ArrowLeftRight}
|
||||
isActive={visibility === 'team'}
|
||||
isActive={isTransferringToTeam}
|
||||
onClick={() => {
|
||||
handleClick('team');
|
||||
setIsTransferringToTeam(true);
|
||||
}}
|
||||
className='border-red-300 text-red-600 hover:border-red-200 hover:bg-red-50 data-[active="true"]:border-red-600 data-[active="true"]:bg-red-600 data-[active="true"]:text-white'
|
||||
/>
|
||||
@@ -115,7 +129,7 @@ function OptionTab(props: OptionTabProps) {
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-black hover:border-gray-300 hover:bg-gray-100',
|
||||
'data-[active="true"]:border-gray-500 data-[active="true"]:bg-gray-200 data-[active="true"]:text-black',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
data-active={isActive}
|
||||
disabled={isActive}
|
||||
|
||||
@@ -82,25 +82,24 @@ export function ShareSuccess(props: ShareSuccessProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<p className="text-sm text-gray-400">
|
||||
You can also embed this roadmap on your website.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(embedHtml);
|
||||
}}
|
||||
readOnly={true}
|
||||
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
|
||||
value={embedHtml}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibility === 'public' && (
|
||||
<>
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<p className="text-sm text-gray-400">
|
||||
You can also embed this roadmap on your website.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(embedHtml);
|
||||
}}
|
||||
readOnly={true}
|
||||
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
|
||||
value={embedHtml}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-4 mt-4 flex items-center gap-1.5">
|
||||
<span className="h-px grow bg-gray-300" />
|
||||
<span className="px-2 text-xs uppercase text-gray-400">Or</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ type TransferToTeamListProps = {
|
||||
teams: UserTeamItem[];
|
||||
setTeams: (teams: UserTeamItem[]) => void;
|
||||
|
||||
currentTeamId?: string;
|
||||
selectedTeamId: string | null;
|
||||
setSelectedTeamId: (teamId: string | null) => void;
|
||||
|
||||
@@ -24,6 +25,7 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
selectedTeamId,
|
||||
setSelectedTeamId,
|
||||
isTeamMembersLoading,
|
||||
currentTeamId,
|
||||
setIsTeamMembersLoading,
|
||||
onTeamChange,
|
||||
} = props;
|
||||
@@ -38,7 +40,7 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<UserTeamItem[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`,
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
@@ -46,7 +48,7 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
}
|
||||
|
||||
setTeams(
|
||||
response.filter((team) => ['admin', 'manager'].includes(team.role))
|
||||
response.filter((team) => ['admin', 'manager'].includes(team.role)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,13 +82,16 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{teams.map((team) => {
|
||||
const isSelected = team._id === selectedTeamId;
|
||||
if (team._id === currentTeamId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={team._id}>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5 disabled:cursor-not-allowed disabled:opacity-70',
|
||||
isSelected && 'border-gray-500 bg-gray-100 text-black'
|
||||
isSelected && 'border-gray-500 bg-gray-100 text-black',
|
||||
)}
|
||||
disabled={isTeamMembersLoading}
|
||||
onClick={() => {
|
||||
|
||||
@@ -3,6 +3,9 @@ import { getRelativeTimeString } from '../../lib/date';
|
||||
import type { TeamStreamActivity } from './TeamActivityPage';
|
||||
import { ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||
import { ActivityTopicTitles } from '../Activity/ActivityTopicTitles';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
|
||||
type TeamActivityItemProps = {
|
||||
onTopicClick?: (activity: TeamStreamActivity) => void;
|
||||
@@ -13,6 +16,7 @@ type TeamActivityItemProps = {
|
||||
name: string;
|
||||
avatar?: string | undefined;
|
||||
username?: string | undefined;
|
||||
memberId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,6 +24,7 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
const { user, onTopicClick, teamId } = props;
|
||||
const { activities } = user;
|
||||
|
||||
const currentTeam = useStore($currentTeam);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const resourceLink = (activity: TeamStreamActivity) => {
|
||||
@@ -60,15 +65,33 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const isPersonalProgressOnly =
|
||||
currentTeam?.personalProgressOnly &&
|
||||
currentTeam.role === 'member' &&
|
||||
user.memberId !== currentTeam.memberId;
|
||||
const username = (
|
||||
<>
|
||||
<a
|
||||
href={`/team/member?t=${teamId}&m=${user?.memberId}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 underline underline-offset-2 hover:underline',
|
||||
isPersonalProgressOnly
|
||||
? 'pointer-events-none cursor-default no-underline'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isPersonalProgressOnly) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={isPersonalProgressOnly}
|
||||
>
|
||||
<img
|
||||
className="mr-1 inline-block h-5 w-5 rounded-full"
|
||||
className="inline-block h-5 w-5 rounded-full"
|
||||
src={userAvatar}
|
||||
alt={user.name}
|
||||
/>
|
||||
<span className="font-medium">{user?.name || 'Unknown'}</span>{' '}
|
||||
</>
|
||||
<span className="font-medium">{user?.name || 'Unknown'}</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (activities.length === 1) {
|
||||
@@ -83,30 +106,45 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
>
|
||||
{actionType === 'in_progress' && (
|
||||
<>
|
||||
{username} started{' '}
|
||||
<p className="mb-1 flex w-full flex-wrap items-center">
|
||||
{username} started
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles
|
||||
className="pl-5"
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => onTopicClick?.(activity)}
|
||||
/>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{actionType === 'done' && (
|
||||
<>
|
||||
{username} completed{' '}
|
||||
<p className="mb-1 flex w-full flex-wrap items-center">
|
||||
{username} completed
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles
|
||||
className="pl-5"
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => onTopicClick?.(activity)}
|
||||
/>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{actionType === 'answered' && (
|
||||
<>
|
||||
{username} answered {topicCount} question
|
||||
{topicCount > 1 ? 's' : ''} in {resourceLink(activity)}{' '}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
<p className="mb-1 flex w-full flex-wrap items-center">
|
||||
{username} answered
|
||||
{topicCount} question{topicCount > 1 ? 's' : ''}
|
||||
in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles
|
||||
className="pl-5"
|
||||
topicTitles={topicTitles || []}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
@@ -122,42 +160,55 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
return (
|
||||
<li key={user._id} className="overflow-hidden rounded-md border">
|
||||
<h3 className="flex flex-wrap items-center gap-1 bg-gray-100 px-2 py-2.5 text-sm">
|
||||
{username} has {activities.length} updates in {uniqueResourcesCount}{' '}
|
||||
resource(s)
|
||||
{username} has {activities.length} updates in {uniqueResourcesCount}
|
||||
resource(s)
|
||||
</h3>
|
||||
<div className="py-3">
|
||||
<ul className="ml-2 flex flex-col gap-2 sm:ml-[36px]">
|
||||
{activities.slice(0, activityLimit).map((activity) => {
|
||||
<ul className="ml-2 flex flex-col divide-y pr-2 sm:ml-[36px]">
|
||||
{activities.slice(0, activityLimit).map((activity, counter) => {
|
||||
const { actionType, topicTitles } = activity;
|
||||
const topicCount = topicTitles?.length || 0;
|
||||
|
||||
return (
|
||||
<li key={activity._id} className="text-sm text-gray-600">
|
||||
<li
|
||||
key={activity._id}
|
||||
className={cn(
|
||||
'text-sm text-gray-600',
|
||||
counter === 0 ? 'pb-2.5' : 'py-2.5',
|
||||
counter === activities.length - 1 ? 'pb-0' : '',
|
||||
)}
|
||||
>
|
||||
{actionType === 'in_progress' && (
|
||||
<>
|
||||
Started{' '}
|
||||
<ActivityTopicTitles
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => onTopicClick?.(activity)}
|
||||
/>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
<p className="mb-1">
|
||||
Started {topicCount} topic
|
||||
{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles topicTitles={topicTitles || []} />
|
||||
</>
|
||||
)}
|
||||
{actionType === 'done' && (
|
||||
<>
|
||||
Completed{' '}
|
||||
<ActivityTopicTitles
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => onTopicClick?.(activity)}
|
||||
/>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
<p className="mb-1">
|
||||
Completed {topicCount} topic
|
||||
{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles topicTitles={topicTitles || []} />
|
||||
</>
|
||||
)}
|
||||
{actionType === 'answered' && (
|
||||
<>
|
||||
Answered {topicCount} question
|
||||
{topicCount > 1 ? 's' : ''} in {resourceLink(activity)}{' '}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
<p className="mb-1">
|
||||
Answered {topicCount} question
|
||||
{topicCount > 1 ? 's' : ''} in
|
||||
{resourceLink(activity)}
|
||||
{timeAgo(activity.updatedAt)}
|
||||
</p>
|
||||
<ActivityTopicTitles topicTitles={topicTitles || []} />
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -39,6 +39,7 @@ type GetTeamActivityResponse = {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
username?: string;
|
||||
memberId?: string;
|
||||
}[];
|
||||
activities: TeamActivityStreamDocument[];
|
||||
};
|
||||
@@ -188,10 +189,10 @@ export function TeamActivityPage() {
|
||||
Team Activity
|
||||
</h3>
|
||||
<ul className="mb-4 mt-2 flex flex-col gap-3">
|
||||
{usersWithActivities.map((user) => {
|
||||
{usersWithActivities.map((user, index) => {
|
||||
return (
|
||||
<TeamActivityItem
|
||||
key={user._id}
|
||||
key={`${user._id}-${index}`}
|
||||
user={user}
|
||||
teamId={teamId}
|
||||
onTopicClick={setSelectedActivity}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, List, ListTodo } from 'lucide-react';
|
||||
import { ListTodo } from 'lucide-react';
|
||||
|
||||
type TeamActivityItemProps = {
|
||||
teamId: string;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type UserTeamItem = {
|
||||
role: AllowedRoles;
|
||||
status: AllowedMemberStatus;
|
||||
memberId: string;
|
||||
personalProgressOnly?: boolean;
|
||||
};
|
||||
|
||||
export type TeamListResponse = UserTeamItem[];
|
||||
|
||||
215
src/components/TeamMemberDetails/TeamMemberDetailsPage.tsx
Normal file
215
src/components/TeamMemberDetails/TeamMemberDetailsPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||
import type { TeamActivityStreamDocument } from '../TeamActivity/TeamActivityPage';
|
||||
import { ResourceProgress } from '../Activity/ResourceProgress';
|
||||
import { ActivityStream } from '../Activity/ActivityStream';
|
||||
import { MemberRoleBadge } from '../TeamMembers/RoleBadge';
|
||||
import { TeamMemberEmptyPage } from './TeamMemberEmptyPage';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { MemberProgressModal } from '../TeamProgress/MemberProgressModal';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
import { MemberCustomProgressModal } from '../TeamProgress/MemberCustomProgressModal';
|
||||
|
||||
type GetTeamMemberProgressesResponse = TeamMemberDocument & {
|
||||
name: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
progresses: UserProgress[];
|
||||
};
|
||||
|
||||
type GetTeamMemberActivityResponse = {
|
||||
data: TeamActivityStreamDocument[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
currPage: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export function TeamMemberDetailsPage() {
|
||||
const { t: teamId, m: memberId } = getUrlParams() as { t: string; m: string };
|
||||
|
||||
const toast = useToast();
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
const [memberProgress, setMemberProgress] =
|
||||
useState<GetTeamMemberProgressesResponse | null>(null);
|
||||
const [memberActivity, setMemberActivity] =
|
||||
useState<GetTeamMemberActivityResponse | null>(null);
|
||||
const [currPage, setCurrPage] = useState(1);
|
||||
|
||||
const [selectedResource, setSelectedResource] = useState<{
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
isCustomResource?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const loadMemberProgress = async () => {
|
||||
const { response, error } = await httpGet<GetTeamMemberProgressesResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-progresses/${teamId}/${memberId}`,
|
||||
);
|
||||
if (error || !response) {
|
||||
pageProgressMessage.set('');
|
||||
toast.error(error?.message || 'Failed to load team member');
|
||||
return;
|
||||
}
|
||||
|
||||
setMemberProgress(response);
|
||||
};
|
||||
|
||||
const loadMemberActivity = async (currPage: number = 1) => {
|
||||
const { response, error } = await httpGet<GetTeamMemberActivityResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-activity/${teamId}/${memberId}`,
|
||||
{
|
||||
currPage,
|
||||
},
|
||||
);
|
||||
if (error || !response) {
|
||||
pageProgressMessage.set('');
|
||||
toast.error(error?.message || 'Failed to load team member activity');
|
||||
return;
|
||||
}
|
||||
|
||||
setMemberActivity(response);
|
||||
setCurrPage(response?.currPage || 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.allSettled([loadMemberProgress(), loadMemberActivity()]).finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
},
|
||||
);
|
||||
}, [teamId]);
|
||||
|
||||
if (!teamId || !memberId || !memberProgress || !memberActivity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const avatarUrl = memberProgress?.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${memberProgress?.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const ProgressModal =
|
||||
selectedResource && !selectedResource.isCustomResource
|
||||
? MemberProgressModal
|
||||
: MemberCustomProgressModal;
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedResource && (
|
||||
<ProgressModal
|
||||
teamId={teamId}
|
||||
member={{
|
||||
...memberProgress,
|
||||
_id: memberId,
|
||||
updatedAt: new Date(memberProgress.updatedAt).toISOString(),
|
||||
progress: memberProgress.progresses,
|
||||
}}
|
||||
resourceId={selectedResource.resourceId}
|
||||
resourceType={selectedResource.resourceType}
|
||||
isCustomResource={selectedResource.isCustomResource}
|
||||
onClose={() => setSelectedResource(null)}
|
||||
onShowMyProgress={() => {
|
||||
window.location.href = `/team/member?t=${teamId}&m=${currentTeam?.memberId}`;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={memberProgress?.name}
|
||||
className="h-14 w-14 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="mt-1 text-2xl font-medium">{memberProgress?.name}</h1>
|
||||
<p className="text-sm text-gray-500">{memberProgress?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{memberProgress?.progresses && memberProgress?.progresses?.length > 0 ? (
|
||||
<>
|
||||
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||
Progress Overview
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{memberProgress?.progresses?.map((progress) => {
|
||||
const learningCount = progress.learning || 0;
|
||||
const doneCount = progress.done || 0;
|
||||
const totalCount = progress.total || 0;
|
||||
const skippedCount = progress.skipped || 0;
|
||||
|
||||
return (
|
||||
<ResourceProgress
|
||||
key={progress.resourceId}
|
||||
isCustomResource={progress.isCustomResource!}
|
||||
doneCount={doneCount > totalCount ? totalCount : doneCount}
|
||||
learningCount={
|
||||
learningCount > totalCount ? totalCount : learningCount
|
||||
}
|
||||
totalCount={totalCount}
|
||||
skippedCount={skippedCount}
|
||||
resourceId={progress.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
updatedAt={progress.updatedAt}
|
||||
title={progress.resourceTitle}
|
||||
roadmapSlug={progress.roadmapSlug}
|
||||
showActions={false}
|
||||
onResourceClick={() => {
|
||||
setSelectedResource({
|
||||
resourceId: progress.resourceId,
|
||||
resourceType: progress.resourceType,
|
||||
isCustomResource: progress.isCustomResource,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<TeamMemberEmptyPage teamId={teamId} />
|
||||
)}
|
||||
|
||||
{memberActivity?.data && memberActivity?.data?.length > 0 ? (
|
||||
<>
|
||||
<ActivityStream
|
||||
className="mt-8 p-0 md:m-0 md:mb-4 md:mt-8 md:p-0"
|
||||
activities={
|
||||
memberActivity?.data?.flatMap((act) => act.activity) || []
|
||||
}
|
||||
onResourceClick={(resourceId, resourceType, isCustomResource) => {
|
||||
setSelectedResource({
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Pagination
|
||||
currPage={currPage}
|
||||
totalPages={memberActivity?.totalPages || 1}
|
||||
totalCount={memberActivity?.totalCount || 0}
|
||||
perPage={memberActivity?.perPage || 10}
|
||||
onPageChange={(page) => {
|
||||
pageProgressMessage.set('Loading Activity');
|
||||
loadMemberActivity(page).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
src/components/TeamMemberDetails/TeamMemberEmptyPage.tsx
Normal file
29
src/components/TeamMemberDetails/TeamMemberEmptyPage.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon';
|
||||
|
||||
type TeamMemberEmptyPageProps = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export function TeamMemberEmptyPage(props: TeamMemberEmptyPageProps) {
|
||||
const { teamId } = props;
|
||||
|
||||
return (
|
||||
<div className="rounded-md">
|
||||
<div className="flex flex-col items-center p-7 text-center">
|
||||
<RoadmapIcon className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
|
||||
|
||||
<h2 className="text-lg font-bold sm:text-xl">No Progress</h2>
|
||||
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
|
||||
Progress will appear here as they start tracking their{' '}
|
||||
<a
|
||||
href={`/team/roadmaps?t=${teamId}`}
|
||||
className="mt-4 text-blue-500 hover:underline"
|
||||
>
|
||||
Roadmaps
|
||||
</a>{' '}
|
||||
progress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
|
||||
|
||||
export function MemberRoleBadge({ role }: { role: AllowedRoles }) {
|
||||
type RoleBadgeProps = {
|
||||
role: AllowedRoles;
|
||||
className?: string;
|
||||
};
|
||||
export function MemberRoleBadge(props: RoleBadgeProps) {
|
||||
const { role, className } = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs sm:flex items-center capitalize ${['admin'].includes(role)
|
||||
? 'bg-blue-100 text-blue-700 '
|
||||
: 'bg-gray-100 text-gray-700 '
|
||||
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`}
|
||||
className={cn(
|
||||
`items-center rounded-full px-2 py-0.5 text-xs capitalize sm:flex ${
|
||||
['admin'].includes(role)
|
||||
? 'bg-blue-100 text-blue-700 '
|
||||
: 'bg-gray-100 text-gray-700 '
|
||||
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { MailIcon } from '../ReactIcons/MailIcon';
|
||||
import { MemberActionDropdown } from './MemberActionDropdown';
|
||||
import { MemberRoleBadge } from './RoleBadge';
|
||||
import type { TeamMemberItem } from './TeamMembersPage';
|
||||
import { $canManageCurrentTeam } from '../../stores/team';
|
||||
import { $canManageCurrentTeam, $currentTeam } from '../../stores/team';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type TeamMemberProps = {
|
||||
member: TeamMemberItem;
|
||||
@@ -29,6 +31,7 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
onSendProgressReminder,
|
||||
} = props;
|
||||
|
||||
const currentTeam = useStore($currentTeam);
|
||||
const canManageTeam = useStore($canManageCurrentTeam);
|
||||
const showNoProgressBadge = !member.hasProgress && member.status === 'joined';
|
||||
const allowProgressReminder =
|
||||
@@ -36,6 +39,10 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
!member.hasProgress &&
|
||||
member.status === 'joined' &&
|
||||
member.userId !== userId;
|
||||
const isPersonalProgressOnly =
|
||||
currentTeam?.personalProgressOnly &&
|
||||
currentTeam.role === 'member' &&
|
||||
String(member._id) !== currentTeam.memberId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,7 +66,23 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<h3 className="inline-grid grid-cols-[auto_auto_auto] items-center font-medium">
|
||||
<span className="truncate">{member.name}</span>
|
||||
<a
|
||||
href={`/team/member?t=${member.teamId}&m=${member._id}`}
|
||||
className={cn(
|
||||
'truncate',
|
||||
isPersonalProgressOnly
|
||||
? 'pointer-events-none cursor-default no-underline'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isPersonalProgressOnly) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={isPersonalProgressOnly}
|
||||
>
|
||||
{member.name}
|
||||
</a>
|
||||
{showNoProgressBadge && (
|
||||
<span className="ml-2 rounded-full bg-red-400 px-2 py-0.5 text-xs font-normal text-white">
|
||||
No Progress
|
||||
@@ -109,4 +132,4 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ export function MemberCustomProgressModal(props: ProgressMapProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div
|
||||
id="original-roadmap"
|
||||
className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto"
|
||||
@@ -237,6 +237,7 @@ export function MemberCustomProgressModal(props: ProgressMapProps) {
|
||||
<div className="px-4 pb-2">
|
||||
<ReadonlyEditor
|
||||
variant="modal"
|
||||
hasMinimap={false}
|
||||
roadmap={roadmap!}
|
||||
className="min-h-[400px]"
|
||||
onRendered={() => {
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { TeamMember } from './TeamProgressPage';
|
||||
import { useState } from 'react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type MemberProgressItemProps = {
|
||||
member: TeamMember;
|
||||
onShowResourceProgress: (
|
||||
resourceId: string,
|
||||
isCustomResource: boolean
|
||||
isCustomResource: boolean,
|
||||
) => void;
|
||||
isMyProgress?: boolean;
|
||||
teamId: string;
|
||||
};
|
||||
export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
const { member, onShowResourceProgress, isMyProgress = false } = props;
|
||||
const {
|
||||
member,
|
||||
onShowResourceProgress,
|
||||
isMyProgress = false,
|
||||
teamId,
|
||||
} = props;
|
||||
|
||||
const currentTeam = useStore($currentTeam);
|
||||
const memberProgress = member?.progress?.sort((a, b) => {
|
||||
return b.done - a.done;
|
||||
});
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const isPersonalProgressOnly =
|
||||
currentTeam?.personalProgressOnly &&
|
||||
currentTeam.role === 'member' &&
|
||||
String(member._id) !== currentTeam.memberId;
|
||||
const memberDetailsUrl = `/team/member?t=${teamId}&m=${member._id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -36,11 +52,43 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
/>
|
||||
<div className="inline-grid w-full">
|
||||
{!isMyProgress && (
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
<a
|
||||
href={memberDetailsUrl}
|
||||
className={cn(
|
||||
'truncate font-medium',
|
||||
isPersonalProgressOnly
|
||||
? 'pointer-events-none cursor-default no-underline'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isPersonalProgressOnly) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={isPersonalProgressOnly}
|
||||
>
|
||||
{member.name}
|
||||
</a>
|
||||
)}
|
||||
{isMyProgress && (
|
||||
<div className="inline-grid grid-cols-[auto,32px] items-center gap-1.5">
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
<a
|
||||
href={memberDetailsUrl}
|
||||
className={cn(
|
||||
'truncate font-medium',
|
||||
isPersonalProgressOnly
|
||||
? 'pointer-events-none cursor-default no-underline'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isPersonalProgressOnly) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={isPersonalProgressOnly}
|
||||
>
|
||||
{member.name}
|
||||
</a>
|
||||
<span className="rounded-md bg-red-500 px-1 py-0.5 text-xs text-white">
|
||||
You
|
||||
</span>
|
||||
@@ -57,7 +105,7 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
onClick={() =>
|
||||
onShowResourceProgress(
|
||||
progress.resourceId,
|
||||
progress.isCustomResource!
|
||||
progress.isCustomResource!,
|
||||
)
|
||||
}
|
||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
@@ -81,7 +129,7 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
},
|
||||
)}
|
||||
|
||||
{memberProgress.length > 4 && !showAll && (
|
||||
|
||||
@@ -260,7 +260,7 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
}, [member]);
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div
|
||||
id={'customized-roadmap'}
|
||||
className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto"
|
||||
|
||||
@@ -227,6 +227,7 @@ export function TeamProgressPage() {
|
||||
<MemberProgressItem
|
||||
key={member._id}
|
||||
member={member}
|
||||
teamId={teamId}
|
||||
isMyProgress={member?.email === user?.email}
|
||||
onShowResourceProgress={(resourceId, isCustomResource) => {
|
||||
setShowMemberProgress({
|
||||
|
||||
@@ -147,6 +147,7 @@ export function TeamRoadmaps() {
|
||||
toast.loading('Adding roadmap');
|
||||
pageProgressMessage.set('Adding roadmap');
|
||||
setIsLoading(true);
|
||||
const roadmap = allRoadmaps.find((r) => r.id === roadmapId);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
@@ -156,6 +157,7 @@ export function TeamRoadmaps() {
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
renderer: roadmap?.renderer || 'balsamiq',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -166,6 +168,9 @@ export function TeamRoadmaps() {
|
||||
|
||||
setTeamResources(response);
|
||||
toast.success('Roadmap added');
|
||||
if (roadmap?.renderer === 'editor') {
|
||||
setIsAddingRoadmap(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
@@ -219,11 +224,14 @@ export function TeamRoadmaps() {
|
||||
/>
|
||||
);
|
||||
|
||||
const filteredAllRoadmaps = allRoadmaps.filter(
|
||||
(r) => !teamResources.find((c) => c?.defaultRoadmapId === r.id),
|
||||
);
|
||||
const addRoadmapModal = isAddingRoadmap && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setIsAddingRoadmap(false)}
|
||||
teamResourceConfig={teamResources}
|
||||
allRoadmaps={allRoadmaps}
|
||||
allRoadmaps={filteredAllRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId: string) => {
|
||||
onAdd(roadmapId).finally(() => {
|
||||
|
||||
@@ -106,6 +106,10 @@ export function TeamVersions(props: TeamVersionsProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTeamVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearResourceProgress();
|
||||
|
||||
// teams have customizations. Assigning #customized-roadmap to roadmapSvgWrap
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
} from '../CustomRoadmap/CustomRoadmap';
|
||||
import { markdownToHtml, sanitizeMarkdown } from '../../lib/markdown';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { Ban, FileText, X } from 'lucide-react';
|
||||
import { Ban, FileText, HeartHandshake, X } from 'lucide-react';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
|
||||
@@ -42,10 +42,10 @@ type TopicDetailProps = {
|
||||
const linkTypes: Record<AllowedLinkTypes, string> = {
|
||||
article: 'bg-yellow-200',
|
||||
course: 'bg-green-200',
|
||||
opensource: 'bg-blue-200',
|
||||
opensource: 'bg-black-200 text-white',
|
||||
podcast: 'bg-purple-200',
|
||||
video: 'bg-pink-200',
|
||||
website: 'bg-red-200',
|
||||
video: 'bg-pink-300',
|
||||
website: 'bg-blue-300',
|
||||
};
|
||||
|
||||
export function TopicDetail(props: TopicDetailProps) {
|
||||
@@ -58,6 +58,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
const [isContributing, setIsContributing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [topicHtml, setTopicHtml] = useState('');
|
||||
const [hasContent, setHasContent] = useState(false);
|
||||
const [topicTitle, setTopicTitle] = useState('');
|
||||
const [topicHtmlTitle, setTopicHtmlTitle] = useState('');
|
||||
const [links, setLinks] = useState<RoadmapContentDocument['links']>([]);
|
||||
@@ -164,9 +165,8 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
}
|
||||
let topicHtml = '';
|
||||
if (!isCustomResource) {
|
||||
topicHtml = response as string;
|
||||
const topicDom = new DOMParser().parseFromString(
|
||||
topicHtml,
|
||||
response as string,
|
||||
'text/html',
|
||||
);
|
||||
|
||||
@@ -177,6 +177,34 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
|
||||
const titleElem: HTMLElement = topicDom.querySelector('h1')!;
|
||||
|
||||
const otherElems = topicDom.querySelectorAll('body > *:not(h1, div)');
|
||||
|
||||
const listLinks = Array.from(
|
||||
topicDom.querySelectorAll('ul > li > a'),
|
||||
).map((link, counter) => {
|
||||
const typePattern = /@([a-z]+)@/;
|
||||
let linkText = link.textContent || '';
|
||||
const linkHref = link.getAttribute('href') || '';
|
||||
const linkType = linkText.match(typePattern)?.[1] || 'article';
|
||||
linkText = linkText.replace(typePattern, '');
|
||||
|
||||
return {
|
||||
id: `link-${linkHref}-${counter}`,
|
||||
title: linkText,
|
||||
url: linkHref,
|
||||
type: linkType as AllowedLinkTypes,
|
||||
};
|
||||
});
|
||||
|
||||
const lastUl = topicDom.querySelector('ul:last-child');
|
||||
if (lastUl) {
|
||||
lastUl.remove();
|
||||
}
|
||||
|
||||
topicHtml = topicDom.body.innerHTML;
|
||||
|
||||
setLinks(listLinks);
|
||||
setHasContent(otherElems.length > 0);
|
||||
setContributionUrl(contributionUrl);
|
||||
setHasEnoughLinks(links.length >= 3);
|
||||
setTopicHtmlTitle(titleElem?.textContent || '');
|
||||
@@ -187,6 +215,8 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
const sanitizedMarkdown = sanitizeMarkdown(
|
||||
(response as RoadmapContentDocument).description || '',
|
||||
);
|
||||
|
||||
setHasContent(sanitizedMarkdown?.length > 0);
|
||||
topicHtml = markdownToHtml(sanitizedMarkdown, false);
|
||||
}
|
||||
|
||||
@@ -207,7 +237,6 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasContent = topicHtml?.length > 0 || links?.length > 0 || topicTitle;
|
||||
const resourceTitleForSearch = resourceTitle
|
||||
?.toLowerCase()
|
||||
?.replace(/\s+?roadmap/gi, '');
|
||||
@@ -218,18 +247,19 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
'https://thenewstack.io/devops/?utm_source=roadmap.sh&utm_medium=Referral&utm_campaign=Topic';
|
||||
|
||||
return (
|
||||
<div className={'relative z-50'}>
|
||||
<div className={'relative z-[90]'}>
|
||||
<div
|
||||
ref={topicRef}
|
||||
tabIndex={0}
|
||||
className="fixed right-0 top-0 z-40 flex h-screen w-full flex-col overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner
|
||||
outerFill="#d1d5db"
|
||||
className="h-6 w-6 sm:h-12 sm:w-12"
|
||||
className="h-6 w-6 sm:h-8 sm:w-8"
|
||||
innerFill="#2563eb"
|
||||
isDualRing={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -241,7 +271,11 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
<div className="mb-2">
|
||||
{!isEmbed && (
|
||||
<TopicProgressButton
|
||||
topicId={topicId}
|
||||
topicId={
|
||||
topicId.indexOf('@') !== -1
|
||||
? topicId.split('@')[1]
|
||||
: topicId
|
||||
}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
onClose={() => {
|
||||
@@ -264,27 +298,55 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
|
||||
{/* Topic Content */}
|
||||
{hasContent ? (
|
||||
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h1:text-balance prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
|
||||
{topicTitle && <h1>{topicTitle}</h1>}
|
||||
<div
|
||||
id="topic-content"
|
||||
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h1:text-balance prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
|
||||
{topicTitle && <h1>{topicTitle}</h1>}
|
||||
<div
|
||||
id="topic-content"
|
||||
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-[calc(100%-38px)] flex-col items-center justify-center">
|
||||
<FileText className="h-16 w-16 text-gray-300" />
|
||||
<p className="mt-2 text-lg font-medium text-gray-500">
|
||||
Empty Content
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
{!canSubmitContribution && (
|
||||
<div className="flex h-[calc(100%-38px)] flex-col items-center justify-center">
|
||||
<FileText className="h-16 w-16 text-gray-300" />
|
||||
<p className="mt-2 text-lg font-medium text-gray-500">
|
||||
Empty Content
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{canSubmitContribution && (
|
||||
<div className="mx-auto flex h-[calc(100%-38px)] max-w-[400px] flex-col items-center justify-center text-center">
|
||||
<HeartHandshake className="mb-2 h-16 w-16 text-gray-300" />
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
Help us write this content
|
||||
</p>
|
||||
<p className="mb-3 mt-2 text-sm text-gray-500">
|
||||
Write a brief introduction to this topic and submit a
|
||||
link to a good article, podcast, video, or any other
|
||||
self-vetted resource that helped you understand this
|
||||
topic better.
|
||||
</p>
|
||||
<a
|
||||
href={contributionUrl}
|
||||
target={'_blank'}
|
||||
className="flex w-full items-center justify-center rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
|
||||
>
|
||||
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-white" />
|
||||
Edit this Content
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<ul className="mt-6 space-y-1">
|
||||
{links.map((link) => {
|
||||
return (
|
||||
<li>
|
||||
<li key={link.id}>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
@@ -293,7 +355,9 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
<span
|
||||
className={cn(
|
||||
'mr-2 inline-block rounded px-1.5 py-1 text-xs uppercase no-underline',
|
||||
linkTypes[link.type],
|
||||
link.type in linkTypes
|
||||
? linkTypes[link.type]
|
||||
: 'bg-gray-200',
|
||||
)}
|
||||
>
|
||||
{link.type.charAt(0).toUpperCase() +
|
||||
@@ -312,8 +376,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
<div className="mb-12 mt-3 border-t text-sm text-gray-400">
|
||||
<div className="mb-4 mt-3">
|
||||
<p className="">
|
||||
Can't find what you're looking for? Try these pre-filled
|
||||
search queries:
|
||||
Find more resources using these pre-filled search queries:
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2 text-gray-700">
|
||||
<a
|
||||
|
||||
@@ -82,7 +82,6 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
'l',
|
||||
() => {
|
||||
if (progress === 'learning') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,7 +137,9 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
)
|
||||
.then(() => {
|
||||
setProgress(progress);
|
||||
onClose();
|
||||
if (progress !== 'learning') {
|
||||
onClose();
|
||||
}
|
||||
renderTopicProgress(topicId, progress);
|
||||
refreshProgressCounters();
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ModalLoader(props: ModalLoaderProps) {
|
||||
const { isLoading, text, error } = props;
|
||||
|
||||
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 flex h-full w-full items-center justify-center">
|
||||
<div className="popup-body relative rounded-lg bg-white p-5 shadow">
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -156,7 +156,7 @@ export function UserCustomProgressModal(props: ProgressMapProps) {
|
||||
return (
|
||||
<div
|
||||
id={'user-progress-modal'}
|
||||
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"
|
||||
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
|
||||
|
||||
@@ -11,6 +11,9 @@ import { useAuth } from '../../hooks/use-auth';
|
||||
import { ModalLoader } from './ModalLoader.tsx';
|
||||
import { UserProgressModalHeader } from './UserProgressModalHeader';
|
||||
import { X } from 'lucide-react';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu.tsx';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
|
||||
import { renderFlowJSON } from '../../../editor/renderer/renderer.ts';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
userId?: string;
|
||||
@@ -18,6 +21,7 @@ export type ProgressMapProps = {
|
||||
resourceType: ResourceType;
|
||||
onClose?: () => void;
|
||||
isCustomResource?: boolean;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
};
|
||||
|
||||
export type UserProgressResponse = {
|
||||
@@ -39,6 +43,7 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
resourceType,
|
||||
userId: propUserId,
|
||||
onClose: onModalClose,
|
||||
renderer = 'balsamiq',
|
||||
} = props;
|
||||
|
||||
const { s: userId = propUserId } = getUrlParams();
|
||||
@@ -87,15 +92,18 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
|
||||
async function getRoadmapSVG(
|
||||
jsonUrl: string,
|
||||
renderer: AllowedRoadmapRenderer = 'balsamiq',
|
||||
): Promise<SVGElement | undefined> {
|
||||
const { error, response: roadmapJson } = await httpGet(jsonUrl);
|
||||
if (error || !roadmapJson) {
|
||||
throw error || new Error('Something went wrong. Please try again!');
|
||||
}
|
||||
|
||||
return await wireframeJSONToSVG(roadmapJson, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
return renderer === 'editor'
|
||||
? await renderFlowJSON(roadmapJson as any)
|
||||
: await wireframeJSONToSVG(roadmapJson, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
@@ -124,8 +132,10 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
Promise.all([
|
||||
getRoadmapSVG(resourceJsonUrl),
|
||||
getRoadmapSVG(resourceJsonUrl, renderer),
|
||||
getUserProgress(userId, resourceType, resourceId),
|
||||
])
|
||||
.then(([svg, user]) => {
|
||||
@@ -199,7 +209,7 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
return (
|
||||
<div
|
||||
id={'user-progress-modal'}
|
||||
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"
|
||||
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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,4 +4,4 @@
|
||||
|
||||
404 request can slow down the performance of your website and negatively impact the user experience. Additionally, they can also cause search engines to crawl and index non-existent pages, which can negatively impact your search engine rankings. To avoid 404 requests, ensure that all links on your website are valid and that any broken links are fixed promptly.
|
||||
|
||||
- [How to avoid Bad Requests?](https://varvy.com/pagespeed/avoid-bad-requests.html)
|
||||
- [404 Not Found Overview](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404)
|
||||
@@ -17,4 +17,3 @@ Instead of using Base64 encoded images, it is generally recommended to use binar
|
||||
- [Base64 Encoding & Performance, Part 1 and 2 by Harry Roberts](https://csswizardry.com/2017/02/base64-encoding-and-performance/)
|
||||
- [A closer look at Base64 image performance – The Page Not Found Blog](http://www.andygup.net/a-closer-look-at-base64-image-performance/)
|
||||
- [When to base64 encode images (and when not to) | David Calhoun](https://www.davidbcalhoun.com/2011/when-to-base64-encode-images-and-when-not-to/)
|
||||
- [Base64 encoding images for faster pages | Performance and seo factors](https://varvy.com/pagespeed/base64-images.html)
|
||||
|
||||
@@ -10,7 +10,6 @@ Optimized images load faster in your browser and consume less data.
|
||||
- Use a tool and specify a level compression under 85.
|
||||
|
||||
- [Image Optimization](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/image-optimization)
|
||||
- [Essential Image Optimization](https://images.guide/)
|
||||
- [TinyJPG – Compress JPEG images intelligently](https://tinyjpg.com/)
|
||||
- [Kraken.io - Online Image Optimizer](https://kraken.io/web-interface)
|
||||
- [Compressor.io](https://compressor.io/compress)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Enable Compression
|
||||
|
||||
Use a compression method such as Gzip or Brotli to reduce the size of your JavaScript files. With a smaller sizes file, users will be able to download the asset faster, resulting in improved performance.
|
||||
Use a compression method such as Gzip or Brotli to reduce the size of your JavaScript files. With smaller file sizes, users will be able to download the asset faster, resulting in improved performance.
|
||||
|
||||
- [Check GZIP compression](https://checkgzipcompression.net/)
|
||||
- [Check Brotli Compression](https://tools.keycdn.com/brotli-test)
|
||||
|
||||
@@ -4,5 +4,4 @@ Reduce as much as you can the time your browser waits before receiving data.
|
||||
|
||||
- [What is Waiting (TTFB) in DevTools, and what to do about it](https://scaleyourcode.com/blog/article/27)
|
||||
- [Monitoring your servers with free tools is easy](https://scaleyourcode.com/blog/article/7)
|
||||
- [Time to First Byte (TTFB)](https://varvy.com/pagespeed/ttfb.html)
|
||||
- [Global latency testing tool](https://latency.apex.sh)
|
||||
|
||||
@@ -6,7 +6,7 @@ When CSS files are minified, the content is loaded faster and less data is sent
|
||||
|
||||
Use tools to minify your files automatically before or during your build or your deployment.
|
||||
|
||||
- [cssnano: A modular minifier based on the PostCSS ecosystem. - cssnano](https://cssnano.co/)
|
||||
- [cssnano: A modular minifier based on the PostCSS ecosystem. - cssnano](https://cssnano.github.io/cssnano/)
|
||||
- [CSS Minifier](https://goonlinetools.com/css-minifier/)
|
||||
- [@neutrinojs/style-minify - npm](https://www.npmjs.com/package/@neutrinojs/style-minify)
|
||||
- [Online CSS Compressor](http://refresh-sf.com)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Minimize HTTP Requests
|
||||
|
||||
> Always ensure that every file requested are essential for your website or application.
|
||||
> Combine files, Enable caching, use a content delivery network and Eliminate unnecessary resources
|
||||
|
||||
- [Combine external CSS](https://varvy.com/pagespeed/combine-external-css.html)
|
||||
- [Combine external JavaScript](https://varvy.com/pagespeed/combine-external-javascript.html)
|
||||
- [Best Strategies for Minimizing HTTP Requests](https://www.linkedin.com/advice/3/what-best-strategies-minimizing-http-requests-skills-e-commerce)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user