Compare commits

..

26 Commits

Author SHA1 Message Date
Arik Chakma
eaf5aee2f5 wip: add more questions 2023-09-26 20:10:11 +06:00
Arik Chakma
aee10fac37 wip: add more questions 2023-09-26 19:48:50 +06:00
Arik Chakma
c70ee5c5f3 wip: add more questions 2023-09-26 19:16:29 +06:00
Arik Chakma
b39de5f670 wip: add more questions 2023-09-26 01:28:01 +06:00
Arik Chakma
802b84ad79 wip: add more questions 2023-09-26 01:04:48 +06:00
Arik Chakma
ff000c87ed wip: add more questions 2023-09-26 00:32:14 +06:00
Arik Chakma
1604cb9d8c wip: add more questions 2023-09-25 18:23:17 +06:00
Arik Chakma
3fab75d44c wip: add more questions 2023-09-25 17:22:21 +06:00
Arik Chakma
7fb089259d wip: add another question 2023-09-24 22:09:09 +06:00
Arik Chakma
6713b059e1 wip: add more questions 2023-09-24 22:01:49 +06:00
Arik Chakma
e9651c6afe wip: add more questions 2023-09-24 21:44:33 +06:00
Arik Chakma
96fe0a5439 wip: add more questions 2023-09-24 21:37:29 +06:00
Arik Chakma
0393a658a7 wip: add more questions 2023-09-24 20:51:45 +06:00
Arik Chakma
4d0143f137 wip: add more questions 2023-09-23 21:42:13 +06:00
Arik Chakma
66eff7af70 wip: add more questions 2023-09-23 18:29:05 +06:00
Arik Chakma
0331e1f782 wip: add more questions 2023-09-23 17:02:44 +06:00
Arik Chakma
0318fe48e3 wip: add more question 2023-09-23 16:04:32 +06:00
Arik Chakma
00ba8a73c1 wip: add more questions 2023-09-23 15:34:50 +06:00
Arik Chakma
81a9baedd0 fix: set example 2023-09-23 14:03:55 +06:00
Arik Chakma
50e26e4fe2 wip: add more questions 2023-09-23 14:01:09 +06:00
Arik Chakma
56473b129c wip: add more questions 2023-09-23 12:24:23 +06:00
Arik Chakma
1dd53d8994 wip: add more questions 2023-09-23 10:15:10 +06:00
Arik Chakma
1b639c433c wip: add more questions 2023-09-23 09:19:09 +06:00
Arik Chakma
041facdc61 wip: add ternary operator 2023-09-22 21:08:41 +06:00
Arik Chakma
e4d770e256 wip: add more questions 2023-09-22 19:17:58 +06:00
Arik Chakma
81bbb42e34 Add Javascript questions 2023-09-22 17:48:48 +06:00
170 changed files with 2565 additions and 16850 deletions

View File

@@ -1,3 +1,2 @@
PUBLIC_API_URL=http://api.roadmap.sh
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
PUBLIC_EDITOR_APP_URL=

View File

@@ -27,7 +27,6 @@ jobs:
pnpm install
- name: Generate meta and build
run: |
npm run generate-renderer
npm run build
touch ./dist/.nojekyll
echo 'roadmap.sh' > ./dist/CNAME

5
.gitignore vendored
View File

@@ -1,5 +1,4 @@
.idea
.temp
# build output
dist/
@@ -28,7 +27,3 @@ pnpm-debug.log*
/playwright/.cache/
tests-examples
*.csv
/renderer/*
!/renderer/index.tsx
!/renderer/renderer.ts

11028
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,6 @@
"roadmap-content": "node scripts/roadmap-content.cjs",
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
"best-practice-content": "node scripts/best-practice-content.cjs",
"generate-renderer": "sh scripts/generate-renderer.sh",
"test:e2e": "playwright test"
},
"dependencies": {
@@ -31,12 +30,10 @@
"@types/react-dom": "^18.0.6",
"astro": "^3.0.5",
"astro-compress": "^2.0.8",
"clsx": "^2.0.0",
"dracula-prism": "^2.1.13",
"jose": "^4.14.4",
"js-cookie": "^3.0.5",
"lucide-react": "^0.274.0",
"nanoid": "^4.0.2",
"nanostores": "^0.9.2",
"node-html-parser": "^6.1.5",
"npm-check-updates": "^16.10.12",
@@ -44,11 +41,9 @@
"react": "^18.0.0",
"react-confetti": "^6.1.0",
"react-dom": "^18.0.0",
"reactflow": "^11.8.3",
"rehype-external-links": "^2.1.0",
"roadmap-renderer": "^1.0.6",
"slugify": "^1.6.6",
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3"
},
"devDependencies": {

431
pnpm-lock.yaml generated
View File

@@ -32,9 +32,6 @@ dependencies:
astro-compress:
specifier: ^2.0.8
version: 2.0.8
clsx:
specifier: ^2.0.0
version: 2.0.0
dracula-prism:
specifier: ^2.1.13
version: 2.1.13
@@ -47,9 +44,6 @@ dependencies:
lucide-react:
specifier: ^0.274.0
version: 0.274.0(react@18.0.0)
nanoid:
specifier: ^4.0.2
version: 4.0.2
nanostores:
specifier: ^0.9.2
version: 0.9.2
@@ -71,9 +65,6 @@ dependencies:
react-dom:
specifier: ^18.0.0
version: 18.0.0(react@18.0.0)
reactflow:
specifier: ^11.8.3
version: 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
rehype-external-links:
specifier: ^2.1.0
version: 2.1.0
@@ -83,9 +74,6 @@ dependencies:
slugify:
specifier: ^1.6.6
version: 1.6.6
tailwind-merge:
specifier: ^1.14.0
version: 1.14.0
tailwindcss:
specifier: ^3.3.3
version: 3.3.3
@@ -1068,114 +1056,6 @@ packages:
config-chain: 1.1.13
dev: false
/@reactflow/background@11.2.8(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
resolution: {integrity: sha512-5o41N2LygiNC2/Pk8Ak2rIJjXbKHfQ23/Y9LFsnAlufqwdzFqKA8txExpsMoPVHHlbAdA/xpQaMuoChGPqmyDw==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
classcat: 5.0.4
react: 18.0.0
react-dom: 18.0.0(react@18.0.0)
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/controls@11.1.19(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
resolution: {integrity: sha512-Vo0LFfAYjiSRMLEII/aeBo+1MT2a0Yc7iLVnkuRTLzChC0EX+A2Fa+JlzeOEYKxXlN4qcDxckRNGR7092v1HOQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
classcat: 5.0.4
react: 18.0.0
react-dom: 18.0.0(react@18.0.0)
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/core@11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
resolution: {integrity: sha512-y6DN8Wy4V4KQBGHFqlj9zWRjLJU6CgdnVwWaEA/PdDg/YUkFBMpZnXqTs60czinoA2rAcvsz50syLTPsj5e+Wg==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@types/d3': 7.4.0
'@types/d3-drag': 3.0.3
'@types/d3-selection': 3.0.6
'@types/d3-zoom': 3.0.4
classcat: 5.0.4
d3-drag: 3.0.0
d3-selection: 3.0.0
d3-zoom: 3.0.0
react: 18.0.0
react-dom: 18.0.0(react@18.0.0)
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/minimap@11.6.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
resolution: {integrity: sha512-PSA28dk09RnBHOA1zb45fjQXz3UozSJZmsIpgq49O3trfVFlSgRapxNdGsughWLs7/emg2M5jmi6Vc+ejcfjvQ==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
'@types/d3-selection': 3.0.6
'@types/d3-zoom': 3.0.4
classcat: 5.0.4
d3-selection: 3.0.0
d3-zoom: 3.0.0
react: 18.0.0
react-dom: 18.0.0(react@18.0.0)
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/node-resizer@2.1.5(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
resolution: {integrity: sha512-z/hJlsptd2vTx13wKouqvN/Kln08qbkA+YTJLohc2aJ6rx3oGn9yX4E4IqNxhA7zNqYEdrnc1JTEA//ifh9z3w==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
classcat: 5.0.4
d3-drag: 3.0.0
d3-selection: 3.0.0
react: 18.0.0
react-dom: 18.0.0(react@18.0.0)
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/node-toolbar@1.2.7(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
resolution: {integrity: sha512-vs+Wg1tjy3SuD7eoeTqEtscBfE9RY+APqC28urVvftkrtsN7KlnoQjqDG6aE45jWP4z+8bvFizRWjAhxysNLkg==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
classcat: 5.0.4
react: 18.0.0
react-dom: 18.0.0(react@18.0.0)
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@sigstore/bundle@1.1.0:
resolution: {integrity: sha512-PFutXEy0SmQxYI4texPw3dd2KewuNqv7OuK1ZFtY2fM754yhvG2KdgwIhRnoEE2uHdtdGNQ8s0lb94dW9sELog==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -1295,185 +1175,6 @@ packages:
'@types/css-tree': 2.3.1
dev: false
/@types/d3-array@3.0.7:
resolution: {integrity: sha512-4/Q0FckQ8TBjsB0VdGFemJOG8BLXUB2KKlL0VmZ+eOYeOnTb/wDRQqYWpBmQ6IlvWkXwkYiot+n9Px2aTJ7zGQ==}
dev: false
/@types/d3-axis@3.0.3:
resolution: {integrity: sha512-SE3x/pLO/+GIHH17mvs1uUVPkZ3bHquGzvZpPAh4yadRy71J93MJBpgK/xY8l9gT28yTN1g9v3HfGSFeBMmwZw==}
dependencies:
'@types/d3-selection': 3.0.6
dev: false
/@types/d3-brush@3.0.3:
resolution: {integrity: sha512-MQ1/M/B5ifTScHSe5koNkhxn2mhUPqXjGuKjjVYckplAPjP9t2I2sZafb/YVHDwhoXWZoSav+Q726eIbN3qprA==}
dependencies:
'@types/d3-selection': 3.0.6
dev: false
/@types/d3-chord@3.0.3:
resolution: {integrity: sha512-keuSRwO02c7PBV3JMWuctIfdeJrVFI7RpzouehvBWL4/GGUB3PBNg/9ZKPZAgJphzmS2v2+7vr7BGDQw1CAulw==}
dev: false
/@types/d3-color@3.1.0:
resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==}
dev: false
/@types/d3-contour@3.0.3:
resolution: {integrity: sha512-x7G/tdDZt4m09XZnG2SutbIuQqmkNYqR9uhDMdPlpJbcwepkEjEWG29euFcgVA1k6cn92CHdDL9Z+fOnxnbVQw==}
dependencies:
'@types/d3-array': 3.0.7
'@types/geojson': 7946.0.10
dev: false
/@types/d3-delaunay@6.0.1:
resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==}
dev: false
/@types/d3-dispatch@3.0.3:
resolution: {integrity: sha512-Df7KW3Re7G6cIpIhQtqHin8yUxUHYAqiE41ffopbmU5+FifYUNV7RVyTg8rQdkEagg83m14QtS8InvNb95Zqug==}
dev: false
/@types/d3-drag@3.0.3:
resolution: {integrity: sha512-82AuQMpBQjuXeIX4tjCYfWjpm3g7aGCfx6dFlxX2JlRaiME/QWcHzBsINl7gbHCODA2anPYlL31/Trj/UnjK9A==}
dependencies:
'@types/d3-selection': 3.0.6
dev: false
/@types/d3-dsv@3.0.2:
resolution: {integrity: sha512-DooW5AOkj4AGmseVvbwHvwM/Ltu0Ks0WrhG6r5FG9riHT5oUUTHz6xHsHqJSVU8ZmPkOqlUEY2obS5C9oCIi2g==}
dev: false
/@types/d3-ease@3.0.0:
resolution: {integrity: sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==}
dev: false
/@types/d3-fetch@3.0.3:
resolution: {integrity: sha512-/EsDKRiQkby3Z/8/AiZq8bsuLDo/tYHnNIZkUpSeEHWV7fHUl6QFBjvMPbhkKGk9jZutzfOkGygCV7eR/MkcXA==}
dependencies:
'@types/d3-dsv': 3.0.2
dev: false
/@types/d3-force@3.0.5:
resolution: {integrity: sha512-EGG+IWx93ESSXBwfh/5uPuR9Hp8M6o6qEGU7bBQslxCvrdUBQZha/EFpu/VMdLU4B0y4Oe4h175nSm7p9uqFug==}
dev: false
/@types/d3-format@3.0.1:
resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==}
dev: false
/@types/d3-geo@3.0.4:
resolution: {integrity: sha512-kmUK8rVVIBPKJ1/v36bk2aSgwRj2N/ZkjDT+FkMT5pgedZoPlyhaG62J+9EgNIgUXE6IIL0b7bkLxCzhE6U4VQ==}
dependencies:
'@types/geojson': 7946.0.10
dev: false
/@types/d3-hierarchy@3.1.3:
resolution: {integrity: sha512-GpSK308Xj+HeLvogfEc7QsCOcIxkDwLhFYnOoohosEzOqv7/agxwvJER1v/kTC+CY1nfazR0F7gnHo7GE41/fw==}
dev: false
/@types/d3-interpolate@3.0.1:
resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==}
dependencies:
'@types/d3-color': 3.1.0
dev: false
/@types/d3-path@3.0.0:
resolution: {integrity: sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==}
dev: false
/@types/d3-polygon@3.0.0:
resolution: {integrity: sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==}
dev: false
/@types/d3-quadtree@3.0.2:
resolution: {integrity: sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==}
dev: false
/@types/d3-random@3.0.1:
resolution: {integrity: sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==}
dev: false
/@types/d3-scale-chromatic@3.0.0:
resolution: {integrity: sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==}
dev: false
/@types/d3-scale@4.0.4:
resolution: {integrity: sha512-eq1ZeTj0yr72L8MQk6N6heP603ubnywSDRfNpi5enouR112HzGLS6RIvExCzZTraFF4HdzNpJMwA/zGiMoHUUw==}
dependencies:
'@types/d3-time': 3.0.0
dev: false
/@types/d3-selection@3.0.6:
resolution: {integrity: sha512-2ACr96USZVjXR9KMD9IWi1Epo4rSDKnUtYn6q2SPhYxykvXTw9vR77lkFNruXVg4i1tzQtBxeDMx0oNvJWbF1w==}
dev: false
/@types/d3-shape@3.1.2:
resolution: {integrity: sha512-NN4CXr3qeOUNyK5WasVUV8NCSAx/CRVcwcb0BuuS1PiTqwIm6ABi1SyasLZ/vsVCFDArF+W4QiGzSry1eKYQ7w==}
dependencies:
'@types/d3-path': 3.0.0
dev: false
/@types/d3-time-format@4.0.0:
resolution: {integrity: sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==}
dev: false
/@types/d3-time@3.0.0:
resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==}
dev: false
/@types/d3-timer@3.0.0:
resolution: {integrity: sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==}
dev: false
/@types/d3-transition@3.0.4:
resolution: {integrity: sha512-512a4uCOjUzsebydItSXsHrPeQblCVk8IKjqCUmrlvBWkkVh3donTTxmURDo1YPwIVDh5YVwCAO6gR4sgimCPQ==}
dependencies:
'@types/d3-selection': 3.0.6
dev: false
/@types/d3-zoom@3.0.4:
resolution: {integrity: sha512-cqkuY1ah9ZQre2POqjSLcM8g40UVya/qwEUrNYP2/rCVljbmqKCVcv+ebvwhlI5azIbSEL7m+os6n+WlYA43aA==}
dependencies:
'@types/d3-interpolate': 3.0.1
'@types/d3-selection': 3.0.6
dev: false
/@types/d3@7.4.0:
resolution: {integrity: sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==}
dependencies:
'@types/d3-array': 3.0.7
'@types/d3-axis': 3.0.3
'@types/d3-brush': 3.0.3
'@types/d3-chord': 3.0.3
'@types/d3-color': 3.1.0
'@types/d3-contour': 3.0.3
'@types/d3-delaunay': 6.0.1
'@types/d3-dispatch': 3.0.3
'@types/d3-drag': 3.0.3
'@types/d3-dsv': 3.0.2
'@types/d3-ease': 3.0.0
'@types/d3-fetch': 3.0.3
'@types/d3-force': 3.0.5
'@types/d3-format': 3.0.1
'@types/d3-geo': 3.0.4
'@types/d3-hierarchy': 3.1.3
'@types/d3-interpolate': 3.0.1
'@types/d3-path': 3.0.0
'@types/d3-polygon': 3.0.0
'@types/d3-quadtree': 3.0.2
'@types/d3-random': 3.0.1
'@types/d3-scale': 4.0.4
'@types/d3-scale-chromatic': 3.0.0
'@types/d3-selection': 3.0.6
'@types/d3-shape': 3.1.2
'@types/d3-time': 3.0.0
'@types/d3-time-format': 4.0.0
'@types/d3-timer': 3.0.0
'@types/d3-transition': 3.0.4
'@types/d3-zoom': 3.0.4
dev: false
/@types/debug@4.1.8:
resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==}
dependencies:
@@ -1484,10 +1185,6 @@ packages:
resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==}
dev: false
/@types/geojson@7946.0.10:
resolution: {integrity: sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==}
dev: false
/@types/hast@2.3.5:
resolution: {integrity: sha512-SvQi0L/lNpThgPoleH53cdjB3y9zpLlVjRbqB3rH8hx1jiRSBGAhyjV3H+URFjNVRqt2EdYNrbZE5IsGlNfpRg==}
dependencies:
@@ -2069,10 +1766,6 @@ packages:
engines: {node: '>=8'}
dev: false
/classcat@5.0.4:
resolution: {integrity: sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g==}
dev: false
/clean-css@5.3.2:
resolution: {integrity: sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==}
engines: {node: '>= 10.0'}
@@ -2298,71 +1991,6 @@ packages:
minimist: 1.2.8
dev: true
/d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
dev: false
/d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
dev: false
/d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
dev: false
/d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
dev: false
/d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
dependencies:
d3-color: 3.1.0
dev: false
/d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
dev: false
/d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
dev: false
/d3-transition@3.0.1(d3-selection@3.0.0):
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
dev: false
/d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
dev: false
/debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
@@ -3211,7 +2839,6 @@ packages:
/iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
requiresBuild: true
dependencies:
safer-buffer: 2.1.2
dev: false
@@ -4260,12 +3887,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
/nanoid@4.0.2:
resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==}
engines: {node: ^14 || ^16 || >=18}
hasBin: true
dev: false
/nanostores@0.9.2:
resolution: {integrity: sha512-wfKlqLGtOYV9+qzGveqDOSWZUBgTeMr/g+JzfV/GofXQ//0wp0cgHF+QBVlmNH/JW9YA9QN+vR6N0vpniPpARA==}
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
@@ -5049,25 +4670,6 @@ packages:
loose-envify: 1.4.0
dev: false
/reactflow@11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
resolution: {integrity: sha512-wuVxJOFqi1vhA4WAEJLK0JWx2TsTiWpxTXTRp/wvpqKInQgQcB49I2QNyNYsKJCQ6jjXektS7H+LXoaVK/pG4A==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/background': 11.2.8(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
'@reactflow/controls': 11.1.19(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
'@reactflow/minimap': 11.6.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
'@reactflow/node-resizer': 2.1.5(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
'@reactflow/node-toolbar': 1.2.7(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
react: 18.0.0
react-dom: 18.0.0(react@18.0.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
dependencies:
@@ -5340,7 +4942,6 @@ packages:
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
requiresBuild: true
dev: false
optional: true
@@ -5753,10 +5354,6 @@ packages:
picocolors: 1.0.0
dev: false
/tailwind-merge@1.14.0:
resolution: {integrity: sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==}
dev: false
/tailwindcss@3.3.3:
resolution: {integrity: sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==}
engines: {node: '>=14.0.0'}
@@ -6086,14 +5683,6 @@ packages:
xdg-basedir: 5.1.0
dev: false
/use-sync-external-store@1.2.0(react@18.0.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.0.0
dev: false
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -6323,26 +5912,6 @@ packages:
resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==}
dev: false
/zustand@4.4.1(@types/react@18.0.21)(react@18.0.0):
resolution: {integrity: sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
dependencies:
'@types/react': 18.0.21
react: 18.0.0
use-sync-external-store: 1.2.0(react@18.0.0)
dev: false
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false

View File

@@ -1,14 +0,0 @@
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>
);
}

View File

@@ -1,5 +0,0 @@
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");
}

View File

@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -e
rm -rf .temp
git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw
rm -rf renderer
mkdir renderer
# copy the files at /src/editor/renderer/* to /renderer
# while replacing any existing files
cp -rf .temp/web-draw/src/editor/renderer/* renderer
# Add @ts-nocheck to the top of each ts and tsx file
# so that the typescript compiler doesn't complain
# about the missing types
find renderer -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= read -r -d '' file; do
if [ -f "$file" ]; then
echo "// @ts-nocheck" > temp
cat "$file" >> temp
mv temp "$file"
echo "Added @ts-nocheck to $file"
fi
done
# remove the temporary directory
rm -rf .temp
# ignore the worktree changes for the renderer directory
git update-index --skip-worktree renderer/*

View File

@@ -2,7 +2,6 @@
import AstroIcon from './AstroIcon.astro';
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
import { SidebarFriendsCounter } from './Friends/SidebarFriendsCounter';
import { Map } from 'lucide-react';
export interface Props {
activePageId: string;
@@ -27,21 +26,10 @@ const sidebarLinks = [
href: '/account/friends',
title: 'Friends',
id: 'friends',
isNew: false,
icon: {
glyph: 'users',
classes: 'h-4 w-4',
},
},
{
href: '/account/roadmaps',
title: 'Roadmaps',
id: 'roadmaps',
isNew: true,
icon: {
glyph: 'users',
classes: 'h-4 w-4',
component: Map,
},
},
{
@@ -112,16 +100,10 @@ const sidebarLinks = [
isActive ? 'bg-slate-100' : ''
}`}
>
{sidebarLink.icon.component ? (
<sidebarLink.icon.component
className={`${sidebarLink.icon.classes} mr-2`}
/>
) : (
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
)}
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
{sidebarLink.title}
</a>
</li>
@@ -154,20 +136,15 @@ const sidebarLinks = [
}`}
>
<span class='flex flex-grow items-center'>
{sidebarLink.icon.component ? (
<sidebarLink.icon.component
className={`${sidebarLink.icon.classes} mr-2`}
/>
) : (
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
)}
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
{sidebarLink.title}
</span>
{sidebarLink.isNew &&
sidebarLink.id !== 'friends' &&
!isActive && (
<span class='relative mr-1 flex items-center'>
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />

View File

@@ -5,17 +5,6 @@ import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
type ProgressResponse = {
updatedAt: string;
title: string;
id: string;
learning: number;
skipped: number;
done: number;
total: number;
isCustomResource: boolean;
};
export type ActivityResponse = {
done: {
today: number;
@@ -24,9 +13,24 @@ export type ActivityResponse = {
learning: {
today: number;
total: number;
roadmaps: ProgressResponse[];
bestPractices: ProgressResponse[];
customs: ProgressResponse[];
roadmaps: {
title: string;
id: string;
learning: number;
done: number;
total: number;
skipped: number;
updatedAt: string;
}[];
bestPractices: {
title: string;
id: string;
learning: number;
done: number;
skipped: number;
total: number;
updatedAt: string;
}[];
};
streak: {
count: number;
@@ -106,8 +110,7 @@ export function ActivityPage() {
})
.map((roadmap) => (
<ResourceProgress
key={roadmap.id}
isCustomResource={roadmap.isCustomResource}
key={roadmap.id}
doneCount={roadmap.done || 0}
learningCount={roadmap.learning || 0}
totalCount={roadmap.total || 0}
@@ -134,8 +137,6 @@ export function ActivityPage() {
})
.map((bestPractice) => (
<ResourceProgress
isCustomResource={bestPractice.isCustomResource}
key={bestPractice.id}
doneCount={bestPractice.done || 0}
totalCount={bestPractice.total || 0}
learningCount={bestPractice.learning || 0}

View File

@@ -3,7 +3,6 @@ import { getRelativeTimeString } from '../../lib/date';
import { useToast } from '../../hooks/use-toast';
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
import { useState } from 'react';
import { getUser } from '../../lib/jwt';
type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice';
@@ -16,17 +15,14 @@ type ResourceProgressType = {
skippedCount: number;
onCleared?: () => void;
showClearButton?: boolean;
isCustomResource: boolean;
};
export function ResourceProgress(props: ResourceProgressType) {
const { showClearButton = true, isCustomResource } = props;
const { showClearButton = true } = props;
const toast = useToast();
const [isClearing, setIsClearing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const userId = getUser()?.id;
const {
updatedAt,
resourceType,
@@ -56,8 +52,8 @@ export function ResourceProgress(props: ResourceProgressType) {
return;
}
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`);
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`);
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
setIsClearing(false);
setIsConfirming(false);
@@ -66,15 +62,11 @@ export function ResourceProgress(props: ResourceProgressType) {
}
}
let url =
const url =
resourceType === 'roadmap'
? `/${resourceId}`
: `/best-practices/${resourceId}`;
if (isCustomResource) {
url = `/r?id=${resourceId}`;
}
const totalMarked = doneCount + skippedCount;
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
@@ -120,7 +112,6 @@ export function ResourceProgress(props: ResourceProgressType) {
<ProgressShareButton
resourceType={resourceType}
resourceId={resourceId}
isCustomResource={isCustomResource}
className="text-xs font-normal"
shareIconClassName="w-2.5 h-2.5 stroke-2"
checkIconClassName="w-2.5 h-2.5"

View File

@@ -1,6 +1,6 @@
import { useRef, useState } from 'react';
import { useOutsideClick } from '../hooks/use-outside-click';
import { type OptionType, SearchSelector } from './SearchSelector';
import { OptionType, SearchSelector } from './SearchSelector';
import type { PageType } from './CommandMenu/CommandMenu';
import { CheckIcon } from './ReactIcons/CheckIcon';
import { httpPut } from '../lib/http';

View File

@@ -36,7 +36,6 @@ function handleGuest() {
'/account/notification',
'/account/update-password',
'/account/settings',
'/account/roadmaps',
'/account/road-card',
'/account/friends',
'/account',

View File

@@ -1,52 +1,37 @@
import { useEffect, useState } from 'react';
import { httpGet, httpPut } from '../../lib/http';
import type { PageType } from '../CommandMenu/CommandMenu';
import ChevronDownIcon from '../../icons/chevron-down.svg';
import { pageProgressMessage } from '../../stores/page';
import type { TeamDocument } from './CreateTeamForm';
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
import { SelectRoadmapModal } from './SelectRoadmapModal';
import { Map, Shapes } from 'lucide-react';
import type {
AllowedRoadmapVisibility,
RoadmapDocument,
} from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { useToast } from '../../hooks/use-toast';
import { NotDropdown } from './NotDropdown';
export type TeamResourceConfig = {
isCustomResource: boolean;
title: string;
visibility?: AllowedRoadmapVisibility;
resourceId: string;
resourceType: string;
removed: string[];
topics?: number;
sharedTeamMemberIds: string[];
sharedFriendIds: string[];
}[];
type RoadmapSelectorProps = {
teamId: string;
teamResources: TeamResourceConfig;
setTeamResources: (config: TeamResourceConfig) => void;
teamResourceConfig: TeamResourceConfig;
setTeamResourceConfig: (config: TeamResourceConfig) => void;
};
export function RoadmapSelector(props: RoadmapSelectorProps) {
const { teamId, teamResources = [], setTeamResources } = props;
const { teamId, teamResourceConfig = [], setTeamResourceConfig } = props;
const toast = useToast();
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState<boolean>(false);
const [error, setError] = useState<string>('');
async function loadAllRoadmaps() {
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (error) {
toast.error(error.message || 'Something went wrong. Please try again!');
setError(error.message || 'Something went wrong. Please try again!');
return;
}
@@ -87,7 +72,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
return;
}
setTeamResources(response);
setTeamResourceConfig(response);
}
async function onRemove(resourceId: string) {
@@ -121,25 +106,13 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
return;
}
setTeamResources(response);
setTeamResourceConfig(response);
}
useEffect(() => {
loadAllRoadmaps().finally(() => {});
loadAllRoadmaps().finally();
}, []);
function handleCustomRoadmapCreated(roadmap: RoadmapDocument) {
const { _id: roadmapId } = roadmap;
if (!roadmapId) {
return;
}
loadAllRoadmaps().finally(() => {});
addTeamResource(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}
return (
<div>
{changingRoadmapId && (
@@ -148,9 +121,9 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
resourceId={changingRoadmapId}
resourceType={'roadmap'}
teamId={teamId}
setTeamResourceConfig={setTeamResources}
setTeamResourceConfig={setTeamResourceConfig}
defaultRemovedItems={
teamResources.find((c) => c.resourceId === changingRoadmapId)
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
?.removed || []
}
/>
@@ -158,7 +131,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
{showSelectRoadmapModal && (
<SelectRoadmapModal
onClose={() => setShowSelectRoadmapModal(false)}
teamResourceConfig={teamResources}
teamResourceConfig={teamResourceConfig}
allRoadmaps={allRoadmaps}
teamId={teamId}
onRoadmapAdd={(roadmapId) => {
@@ -172,170 +145,72 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
/>
)}
<div className="my-3 flex items-center gap-4">
{isCreatingRoadmap && (
<CreateRoadmapModal
teamId={teamId}
onClose={() => setIsCreatingRoadmap(false)}
onCreated={(roadmap: RoadmapDocument) => {
handleCustomRoadmapCreated(roadmap);
setIsCreatingRoadmap(false);
}}
/>
)}
<button
className="flex h-10 grow items-center justify-center gap-2 rounded-md border border-black bg-white text-black transition-colors hover:bg-black hover:text-white"
<div className="mt-3">
<NotDropdown
onClick={() => {
setShowSelectRoadmapModal(true);
}}
>
<Map className="h-4 w-4 stroke-[2.5]" />
Pick from our roadmaps
</button>
<span className="text-base text-gray-400">or</span>
<button
className="flex h-10 grow items-center justify-center gap-2 rounded-md border border-black bg-white text-black transition-colors hover:bg-black hover:text-white"
onClick={() => {
setIsCreatingRoadmap(true);
}}
>
<Shapes className="h-4 w-4 stroke-[2.5]" />
Create Custom Roadmap
</button>
selectedCount={teamResourceConfig.length}
singularName={'roadmap'}
pluralName={'roadmaps'}
/>
</div>
{!teamResources.length && (
<div className="flex min-h-[240px] flex-col items-center justify-center rounded-lg border">
<Map className="mb-2 h-12 w-12 text-gray-300" />
<p className={'text-lg font-semibold'}>No roadmaps selected.</p>
<p className={'text-base text-gray-400'}>
Pick from{' '}
<span
onClick={() => setShowSelectRoadmapModal(true)}
className="cursor-pointer underline"
>
our roadmaps
</span>{' '}
or{' '}
<span
onClick={() => {
setIsCreatingRoadmap(true);
}}
className="cursor-pointer underline"
>
create a new one
</span>
.
</p>
</div>
{!teamResourceConfig.length && (
<p className={'mb-3 mt-2 text-base text-gray-400'}>
No roadmaps selected.
</p>
)}
{teamResources.length > 0 && (
<div className="mb-3 grid grid-cols-1 flex-wrap gap-2.5 sm:grid-cols-3">
{teamResources.map(
({
isCustomResource,
title: roadmapTitle,
resourceId,
removed: removedTopics,
topics,
}) => {
return (
<div
className="relative flex flex-col items-start overflow-hidden rounded-md border border-gray-300"
key={resourceId}
>
<div className={'w-full flex-grow px-3 pb-2 pt-4'}>
<span className="mb-0.5 block text-base font-medium leading-snug text-black">
{roadmapTitle}
{teamResourceConfig.length > 0 && (
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 flex-wrap gap-2.5">
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
const roadmapTitle =
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
'...';
return (
<div className="flex flex-col items-start rounded-md border border-gray-300">
<div className={'w-full px-3 pb-2 pt-4'}>
<span className="mb-0.5 block text-base font-medium leading-none text-black">
{roadmapTitle}
</span>
{removedTopics.length > 0 ? (
<span className={'text-xs leading-none text-gray-900'}>
{removedTopics.length} topic
{removedTopics.length > 1 ? 's' : ''} removed
</span>
) : (
<span className="text-xs italic leading-none text-gray-400/60">
No changes made ..
</span>
{removedTopics.length > 0 || (topics && topics > 0) ? (
<span className={'text-xs leading-none text-gray-400'}>
{isCustomResource ? (
<>
Custom &middot; {topics} topic
{topics && topics > 1 ? 's' : ''}
</>
) : (
<>
{removedTopics.length} topic
{removedTopics.length > 1 ? 's' : ''} removed
</>
)}
</span>
) : (
<span className="text-xs italic leading-none text-gray-400/60">
{isCustomResource
? 'Placeholder roadmap.'
: 'No changes made ..'}
</span>
)}
</div>
{removingRoadmapId === resourceId && (
<div
className={
'flex w-full items-center justify-end p-3 text-sm'
}
>
<span className="text-xs text-gray-500">
Are you sure?{' '}
<button
onClick={() => onRemove(resourceId)}
className="mx-0.5 text-red-500 underline underline-offset-1"
>
Yes
</button>{' '}
<button
onClick={() => setRemovingRoadmapId('')}
className="text-red-500 underline underline-offset-1"
>
No
</button>
</span>
</div>
)}
{(!removingRoadmapId || removingRoadmapId !== resourceId) && (
<div className={'flex w-full justify-between p-3'}>
<button
type="button"
className={
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
}
onClick={() => {
if (isCustomResource) {
window.open(
`${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${resourceId}`,
'_blank'
);
return;
}
setChangingRoadmapId(resourceId);
}}
>
Customize
</button>
<button
type="button"
className={
'text-xs text-red-500 underline hover:text-black'
}
onClick={() => setRemovingRoadmapId(resourceId)}
>
Remove
</button>
</div>
)}
</div>
);
}
)}
<div className={'flex w-full justify-between p-3'}>
<button
type="button"
className={
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
}
onClick={() => setChangingRoadmapId(resourceId)}
>
Customize
</button>
<button
type="button"
className={
'text-xs text-red-500 underline hover:text-black'
}
onClick={() => onRemove(resourceId)}
>
Remove
</button>
</div>
</div>
);
})}
</div>
)}
</div>

View File

@@ -100,13 +100,12 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
{roleBasedRoadmaps.length > 0 && (
<div className="mb-5 flex flex-wrap items-center gap-2">
{roleBasedRoadmaps.map((roadmap) => {
const isSelected = !!teamResourceConfig?.find(
const isSelected = !!teamResourceConfig.find(
(r) => r.resourceId === roadmap.id
);
return (
<SelectRoadmapModalItem
key={roadmap.id}
title={roadmap.title}
isSelected={isSelected}
onClick={() => {
@@ -132,7 +131,6 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
return (
<SelectRoadmapModalItem
key={roadmap.id}
title={roadmap.title}
isSelected={isSelected}
onClick={() => {

View File

@@ -10,15 +10,13 @@ export const validTeamTypes = [
value: 'company',
label: 'Company',
icon: BuildingIcon.src,
description:
'Track the skills and learning progress of the tech team at your company',
description: 'Track the skills and learning progress of the tech team at your company',
},
{
value: 'study_group',
label: 'Study Group',
icon: UsersIcon.src,
description:
'Invite your friends or course-mates and track your learning progress together',
description: 'Invite your friends or course-mates and track your learning progress together',
},
] as const;
@@ -72,11 +70,10 @@ export function Step0(props: Step0Props) {
return (
<>
<div className={'flex flex-col gap-3 sm:flex-row'}>
<div className={'flex flex-col sm:flex-row gap-3'}>
{validTeamTypes.map((validTeamType) => (
<button
key={validTeamType.value}
className={`flex flex-grow flex-col items-center rounded-lg border px-5 pt-12 pb-10 ${
className={`flex flex-grow flex-col items-center rounded-lg border px-5 py-12 ${
validTeamType.value == selectedTeamType
? 'border-gray-400 bg-gray-100'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
@@ -84,7 +81,6 @@ export function Step0(props: Step0Props) {
onClick={() => setSelectedTeamType(validTeamType.value)}
>
<img
key={validTeamType.value}
alt={validTeamType.label}
src={validTeamType.icon}
className={`mb-3 h-12 w-12 opacity-10 ${
@@ -94,7 +90,7 @@ export function Step0(props: Step0Props) {
<span className="mb-2 block text-2xl font-bold">
{validTeamType.label}
</span>
<span className="text-sm leading-[21px] text-gray-500">
<span className="text-sm text-gray-500 leading-[21px]">
{validTeamType.description}
</span>
</button>
@@ -104,11 +100,11 @@ export function Step0(props: Step0Props) {
{/*Error message*/}
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
<a
href="/account"
className={
'rounded-md border border-red-400 bg-white px-8 py-2 text-center text-red-500'
'rounded-md border border-red-400 bg-white px-8 py-2 text-red-500 text-center'
}
>
Cancel

View File

@@ -221,11 +221,11 @@ export function Step1(props: Step1Props) {
setTeamSize((e.target as HTMLSelectElement).value as any)
}
>
<option value="">
<option value="" selected>
Select team size
</option>
{validTeamSizes.map((size) => (
<option key={size} value={size}>{size} people</option>
<option value={size}>{size} people</option>
))}
</select>
</div>

View File

@@ -17,9 +17,7 @@ export function Step2(props: Step2Props) {
<>
<div className="mt-4 flex w-full flex-col">
<div className="mb-1 mt-2">
<h2 className="mb-1 text-lg font-bold md:mb-1.5 md:text-2xl">
Select Roadmaps
</h2>
<h2 className="mb-1 md:mb-1.5 text-lg md:text-2xl font-bold">Select Roadmaps</h2>
<p className="text-sm text-gray-700">
You can always add and customize your roadmaps later.
</p>
@@ -27,12 +25,12 @@ export function Step2(props: Step2Props) {
<RoadmapSelector
teamId={team._id!}
teamResources={teamResourceConfig}
setTeamResources={setTeamResourceConfig}
teamResourceConfig={teamResourceConfig}
setTeamResourceConfig={setTeamResourceConfig}
/>
</div>
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
<button
type="button"
onClick={onBack}
@@ -48,9 +46,8 @@ export function Step2(props: Step2Props) {
<button
type="button"
onClick={onNext}
disabled={teamResourceConfig.length !== 0}
className={
'flex-grow rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black md:flex-auto disabled:opacity-50 disabled:pointer-events-none'
'flex-grow rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black md:flex-auto'
}
>
Skip for Now

View File

@@ -178,9 +178,8 @@ export function Step3(props: Step3Props) {
<button
type="button"
onClick={onNext}
disabled={users.filter((u) => u.email).length !== 0}
className={
'rounded-md flex-grow md:flex-auto border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black disabled:opacity-50 disabled:pointer-events-none'
'rounded-md flex-grow md:flex-auto border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black'
}
>
Skip for Now

View File

@@ -1,13 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import { wireframeJSONToSVG } from 'roadmap-renderer';
import { Spinner } from '../ReactIcons/Spinner';
import { httpPut } from '../../lib/http';
import { httpGet, httpPut } from '../../lib/http';
import { renderTopicProgress } from '../../lib/resource-progress';
import '../FrameRenderer/FrameRenderer.css';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import type { TeamResourceConfig } from './RoadmapSelector';
import { useToast } from '../../hooks/use-toast';
import { useStore } from '@nanostores/react';
import { $currentTeam } from '../../stores/team';
export type ProgressMapProps = {
teamId: string;
@@ -38,6 +40,8 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
const [removedItems, setRemovedItems] =
useState<string[]>(defaultRemovedItems);
const currentTeam = useStore($currentTeam);
useEffect(() => {
function onTopicClick(e: any) {
const groupEl = e.target.closest('.clickable-group');
@@ -65,9 +69,7 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
};
}, [removedItems]);
let resourceJsonUrl = import.meta.env.DEV
? 'http://localhost:3000'
: 'https://roadmap.sh';
let resourceJsonUrl = 'https://roadmap.sh';
if (resourceType === 'roadmap') {
resourceJsonUrl += `/${resourceId}.json`;
} else {
@@ -149,7 +151,11 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
<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="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
<div
id={'customized-roadmap'}
id={
currentTeam?.type === 'company'
? 'customized-roadmap'
: 'original-roadmap'
}
ref={popupBodyEl}
className="popup-body relative rounded-lg bg-white shadow"
>

View File

@@ -1,53 +0,0 @@
import { Plus } from 'lucide-react';
import { isLoggedIn } from '../../../lib/jwt';
import { showLoginPopup } from '../../../lib/popup';
import { cn } from '../../../lib/classname';
import {
type AllowedCustomRoadmapType,
type AllowedRoadmapVisibility,
CreateRoadmapModal,
} from './CreateRoadmapModal';
import { useState } from 'react';
type CreateRoadmapButtonProps = {
className?: string;
type?: AllowedCustomRoadmapType;
};
export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
const { className, type } = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
function toggleCreateRoadmapHandler() {
if (!isLoggedIn()) {
return showLoginPopup();
}
setIsCreatingRoadmap(true);
}
return (
<>
{isCreatingRoadmap && (
<CreateRoadmapModal
type={type}
onClose={() => {
setIsCreatingRoadmap(false);
}}
/>
)}
<button
className={cn(
'flex h-full w-full items-center justify-center gap-1 overflow-hidden rounded-md border border-dashed border-gray-800 p-3 text-sm text-gray-400 hover:border-gray-600 hover:bg-gray-900 hover:text-gray-300',
className
)}
onClick={toggleCreateRoadmapHandler}
>
<Plus size={16} />
Create a new roadmap
</button>
</>
);
}

View File

@@ -1,275 +0,0 @@
import {
type FormEvent,
type MouseEvent,
useEffect,
useRef,
useState,
} from 'react';
import { Loader2 } from 'lucide-react';
import { Modal } from '../../Modal';
import { useToast } from '../../../hooks/use-toast';
import { httpPost } from '../../../lib/http';
import { cn } from '../../../lib/classname';
import { allowedVisibilityLabels } from '../ShareRoadmapModal';
export const allowedRoadmapVisibility = [
'me',
'friends',
'team',
'public',
] as const;
export type AllowedRoadmapVisibility =
(typeof allowedRoadmapVisibility)[number];
export const allowedCustomRoadmapType = ['role', 'skill'] as const;
export type AllowedCustomRoadmapType =
(typeof allowedCustomRoadmapType)[number];
export interface RoadmapDocument {
_id?: string;
title: string;
description?: string;
creatorId: string;
teamId?: string;
type: AllowedCustomRoadmapType;
visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[];
sharedTeamMemberIds?: string[];
nodes: any[];
edges: any[];
createdAt: Date;
updatedAt: Date;
canManage: boolean;
isCustomResource: boolean;
}
interface CreateRoadmapModalProps {
onClose: () => void;
onCreated?: (roadmap: RoadmapDocument) => void;
teamId?: string;
type?: AllowedCustomRoadmapType;
visibility?: AllowedRoadmapVisibility;
}
export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
const { onClose, onCreated, teamId, type: defaultType = 'role' } = props;
const titleRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState<AllowedCustomRoadmapType>(defaultType);
const isInvalidDescription = description?.trim().length > 80;
async function handleSubmit(
e: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>,
redirect: boolean = true
) {
e.preventDefault();
if (isLoading) {
return;
}
if (title.trim() === '' || isInvalidDescription || !type) {
toast.error('Please fill all the fields');
return;
}
setIsLoading(true);
const { response, error } = await httpPost<RoadmapDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-create-roadmap`,
{
title,
description,
type,
...(teamId && {
teamId,
}),
nodes: [],
edges: [],
}
);
if (error) {
setIsLoading(false);
toast.error(error?.message || 'Something went wrong, please try again');
return;
}
toast.success('Roadmap created successfully');
if (redirect) {
window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
response?._id
}`;
return;
}
if (onCreated) {
onCreated(response as RoadmapDocument);
return;
}
onClose();
setTitle('');
setDescription('');
setType('role');
setIsLoading(false);
}
useEffect(() => {
titleRef.current?.focus();
}, []);
return (
<Modal
onClose={onClose}
bodyClassName="p-4"
wrapperClassName={cn(teamId && 'max-w-lg')}
>
<div className="mb-4">
<h2 className="text-lg font-medium text-gray-900">Create Roadmap</h2>
<p className="mt-1 text-sm text-gray-500">
Add a title and description to your roadmap.
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="mt-4">
<label
htmlFor="title"
className="block text-xs uppercase text-gray-400"
>
Roadmap Title
</label>
<div className="mt-1">
<input
ref={titleRef}
type="text"
name="title"
id="title"
required
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
placeholder="Enter Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</div>
<div className="mt-4">
<label
htmlFor="description"
className="block text-xs uppercase text-gray-400"
>
Description
</label>
<div className="relative mt-1">
<textarea
id="description"
name="description"
required
className={cn(
'block h-24 w-full resize-none rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm',
isInvalidDescription && 'border-red-300 bg-red-100'
)}
placeholder="Enter Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
{description.length}/80
</div>
</div>
</div>
<div className="mt-4">
<label
htmlFor="type"
className="block text-xs uppercase text-gray-400"
>
Type
</label>
<div className="mt-1">
<select
id="type"
name="type"
required
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
value={type}
onChange={(e) =>
setType(e.target.value as AllowedCustomRoadmapType)
}
>
{allowedCustomRoadmapType.map((type) => (
<option key={type} value={type}>
{type.charAt(0).toUpperCase() + type.slice(1)} Based Roadmap
</option>
))}
</select>
</div>
</div>
<div
className={cn('mt-4 flex justify-between gap-2', teamId && 'mt-8')}
>
<button
onClick={onClose}
type="button"
className={cn(
'block h-9 rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-gray-300 focus:bg-gray-100',
!teamId && 'w-full'
)}
>
Cancel
</button>
<div className={cn('flex items-center gap-2', !teamId && 'w-full')}>
{teamId && !isLoading && (
<button
disabled={isLoading}
type="button"
onClick={(e) => handleSubmit(e, false)}
className="flex h-9 items-center justify-center rounded-md border border-black bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:bg-black hover:text-white focus:bg-black focus:text-white"
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
'Save as Placeholder'
)}
</button>
)}
<button
disabled={isLoading}
type="submit"
className={cn(
'flex h-9 items-center justify-center rounded-md border border-transparent bg-black px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800',
teamId ? 'hidden sm:flex' : 'w-full'
)}
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : teamId ? (
'Continue to Editor'
) : (
'Create'
)}
</button>
</div>
</div>
{teamId && (
<>
<p className="mt-4 hidden rounded-md border border-orange-200 bg-orange-50 p-2.5 text-sm text-orange-600 sm:block">
Preparing the roadmap might take some time, feel free to save it
as a placeholder and anyone with the role <strong>admin</strong>{' '}
or <strong>manager</strong> can prepare it later.
</p>
<p className="mt-4 rounded-md border border-orange-200 bg-orange-50 p-2.5 text-sm text-orange-600 sm:hidden">
Create a placeholder now and prepare it later.
</p>
</>
)}
</form>
</Modal>
);
}

View File

@@ -1,121 +0,0 @@
import { useEffect, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import {
type AppError,
type FetchError,
httpGet,
httpPost,
} from '../../lib/http';
import { RoadmapHeader } from './RoadmapHeader';
import { RoadmapRenderer } from './RoadmapRenderer';
import { TopicDetail } from '../TopicDetail/TopicDetail';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { currentRoadmap } from '../../stores/roadmap';
import { UserProgressModal } from '../UserProgress/UserProgressModal';
import { RestrictedPage } from './RestrictedPage';
import { isLoggedIn } from '../../lib/jwt';
export const allowedLinkTypes = [
'video',
'article',
'opensource',
'course',
'website',
'podcast',
] as const;
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
export interface RoadmapContentDocument {
_id?: string;
roadmapId: string;
nodeId: string;
title: string;
description: string;
links: {
id: string;
type: AllowedLinkTypes;
title: string;
url: string;
}[];
}
export function hideRoadmapLoader() {
const loaderEl = document.querySelector(
'[data-roadmap-loader]'
) as HTMLElement;
if (loaderEl) {
loaderEl.remove();
}
}
export function CustomRoadmap() {
const { id, secret } = getUrlParams() as { id: string; secret: string };
const [isLoading, setIsLoading] = useState(true);
const [roadmap, setRoadmap] = useState<RoadmapDocument | null>(null);
const [error, setError] = useState<AppError | FetchError | undefined>();
async function getRoadmap() {
setIsLoading(true);
const roadmapUrl = new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`
);
if (secret) {
roadmapUrl.searchParams.set('secret', secret);
}
const { response, error } = await httpGet<RoadmapDocument>(
roadmapUrl.toString()
);
if (error || !response) {
setError(error);
setIsLoading(false);
return;
}
document.title = `${response.title} - roadmap.sh`;
setRoadmap(response);
currentRoadmap.set(response);
setIsLoading(false);
}
async function trackVisit() {
if (!isLoggedIn()) return;
await httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
resourceId: id,
resourceType: 'roadmap',
});
}
useEffect(() => {
getRoadmap().finally(() => {
hideRoadmapLoader();
});
trackVisit().then();
}, []);
if (isLoading) {
return null;
}
if (error) {
return <RestrictedPage error={error} />;
}
return (
<>
<RoadmapHeader />
<RoadmapRenderer roadmap={roadmap!} />
<TopicDetail canSubmitContribution={false} />
<UserProgressModal
resourceId={roadmap?._id!}
resourceType="roadmap"
isCustomResource={true}
/>
</>
);
}

View File

@@ -1,12 +0,0 @@
import { CircleSlash } from 'lucide-react';
export function EmptyRoadmap() {
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center">
<CircleSlash className="mx-auto h-20 w-20 text-gray-400" />
<h3 className="mt-4">This roadmap is currently empty.</h3>
</div>
</div>
);
}

View File

@@ -1,94 +0,0 @@
import MoreIcon from '../../icons/more-vertical.svg';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
type PersonalRoadmapActionDropdownProps = {
onDelete?: () => void;
onCustomize?: () => void;
onUpdateSharing?: () => void;
};
export function PersonalRoadmapActionDropdown(props: PersonalRoadmapActionDropdownProps) {
const { onDelete, onUpdateSharing, onCustomize } = props;
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
useOutsideClick(menuRef, () => {
setIsOpen(false);
});
return (
<div className="relative">
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex"
>
<img alt="menu" src={MoreIcon.src} className="h-4 w-4" />
</button>
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none sm:hidden"
>
<MoreVertical size={14} />
Options
</button>
{isOpen && (
<div
ref={menuRef}
className="align-right absolute right-auto top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md sm:right-0"
>
<ul>
{onUpdateSharing && (
<li>
<button
onClick={() => {
setIsOpen(false);
onUpdateSharing();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Lock size={14} className="mr-2" />
Sharing
</button>
</li>
)}
{onCustomize && (
<li>
<button
onClick={() => {
setIsOpen(false);
onCustomize();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Shapes size={14} className="mr-2" />
Customize
</button>
</li>
)}
{onDelete && (
<li>
<button
onClick={() => {
setIsOpen(false);
onDelete();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Trash2 size={14} className="mr-2" />
Delete
</button>
</li>
)}
</ul>
</div>
)}
</div>
);
}

View File

@@ -1,238 +0,0 @@
import { httpDelete } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import {
ExternalLink,
Shapes,
type LucideIcon,
Globe,
LockIcon,
Users,
} from 'lucide-react';
import { useToast } from '../../hooks/use-toast';
import {
type AllowedRoadmapVisibility,
type RoadmapDocument,
} from './CreateRoadmap/CreateRoadmapModal';
import RoadmapIcon from '../../icons/roadmap.svg';
import { PersonalRoadmapActionDropdown } from './PersonalRoadmapActionDropdown';
import type { GetRoadmapListResponse } from './RoadmapListPage';
import { useState, type Dispatch, type SetStateAction } from 'react';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
type PersonalRoadmapListType = {
roadmaps: GetRoadmapListResponse['personalRoadmaps'];
onDelete: (roadmapId: string) => void;
setAllRoadmaps: Dispatch<SetStateAction<GetRoadmapListResponse>>;
};
export function PersonalRoadmapList(props: PersonalRoadmapListType) {
const { roadmaps: roadmapList, onDelete, setAllRoadmaps } = props;
const toast = useToast();
const [selectedRoadmap, setSelectedRoadmap] = useState<
GetRoadmapListResponse['personalRoadmaps'][number] | null
>(null);
async function deleteRoadmap(roadmapId: string) {
const { response, error } = await httpDelete<RoadmapDocument[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-roadmap/${roadmapId}`
);
if (error || !response) {
console.error(error);
toast.error(error?.message || 'Something went wrong, please try again');
return;
}
toast.success('Roadmap deleted');
onDelete(roadmapId);
}
async function onRemove(roadmapId: string) {
pageProgressMessage.set('Deleting roadmap');
deleteRoadmap(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}
const shareSettingsModal = selectedRoadmap && (
<ShareOptionsModal
visibility={selectedRoadmap.visibility}
sharedFriendIds={selectedRoadmap.sharedFriendIds}
sharedTeamMemberIds={selectedRoadmap.sharedTeamMemberIds}
roadmapId={selectedRoadmap._id!}
onClose={() => setSelectedRoadmap(null)}
onShareSettingsUpdate={(settings) => {
setAllRoadmaps((prev) => {
return {
...prev,
personalRoadmaps: prev.personalRoadmaps.map((roadmap) => {
if (roadmap._id === selectedRoadmap._id) {
return {
...roadmap,
...settings,
};
}
return roadmap;
}),
};
});
}}
/>
);
if (roadmapList.length === 0) {
return (
<div className="flex flex-col items-center p-4 py-20">
<img
alt="roadmap"
src={RoadmapIcon.src}
className="mb-4 h-24 w-24 opacity-10"
/>
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
<p className="text-base text-gray-500">
Create a roadmap to get started
</p>
</div>
);
}
return (
<div>
{shareSettingsModal}
<div className="mb-3 flex items-center justify-between">
<span className={'text-sm text-gray-400'}>
{roadmapList.length} custom roadmap(s)
</span>
</div>
<ul className="flex flex-col divide-y rounded-md border">
{roadmapList.map((roadmap) => {
return (
<CustomRoadmapItem
key={roadmap._id!}
roadmap={roadmap}
onRemove={onRemove}
setSelectedRoadmap={setSelectedRoadmap}
/>
);
})}
</ul>
</div>
);
}
type CustomRoadmapItemProps = {
roadmap: GetRoadmapListResponse['personalRoadmaps'][number];
onRemove: (roadmapId: string) => Promise<void>;
setSelectedRoadmap: (
roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null
) => void;
};
function CustomRoadmapItem(props: CustomRoadmapItemProps) {
const { roadmap, onRemove, setSelectedRoadmap } = props;
const editorLink = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmap._id}`;
return (
<li
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_110px]"
key={roadmap._id!}
>
<div className="mb-3 grid grid-cols-1 sm:mb-0">
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
{roadmap.title}
</p>
<span className="flex items-center text-xs leading-none text-gray-400">
<VisibilityBadge
visibility={roadmap.visibility!}
sharedFriendIds={roadmap.sharedFriendIds}
/>
<span className="mx-2 font-semibold">&middot;</span>
<Shapes size={16} className="mr-1 inline-block h-4 w-4" />
{roadmap.topics} topic
</span>
</div>
<div className="mr-1 flex items-center justify-start sm:justify-end">
<PersonalRoadmapActionDropdown
onUpdateSharing={() => {
setSelectedRoadmap(roadmap);
}}
onCustomize={() => {
window.open(editorLink, '_blank');
}}
onDelete={() => {
if (confirm('Are you sure you want to remove this roadmap?')) {
onRemove(roadmap._id!).finally(() => {});
}
}}
/>
<a
href={`/r?id=${roadmap._id}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
}
target={'_blank'}
>
<ExternalLink className="inline-block h-4 w-4" />
Visit
</a>
</div>
</li>
);
}
type VisibilityLabelProps = {
visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[];
};
const visibilityDetails: Record<
AllowedRoadmapVisibility,
{
icon: LucideIcon;
label: string;
}
> = {
public: {
icon: Globe,
label: 'Public',
},
me: {
icon: LockIcon,
label: 'Only me',
},
team: {
icon: Users,
label: 'Team Member(s)',
},
friends: {
icon: Users,
label: 'Friend(s)',
},
} as const;
function VisibilityBadge(props: VisibilityLabelProps) {
const { visibility, sharedFriendIds = [] } = props;
const { label, icon: Icon } = visibilityDetails[visibility];
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`}
>
<Icon className="inline-block h-3 w-3" />
<div className="flex items-center">
{visibility === 'friends' && sharedFriendIds?.length > 0 && (
<span className="mr-1">{sharedFriendIds.length}</span>
)}
{label}
</div>
</span>
);
}

View File

@@ -1,111 +0,0 @@
import { HelpCircle } from 'lucide-react';
import { cn } from '../../lib/classname';
import type { ResourceType } from '../../lib/resource-progress';
import { useState } from 'react';
import { useStore } from '@nanostores/react';
import { canManageCurrentRoadmap, currentRoadmap } from '../../stores/roadmap';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
type ResourceProgressStatsProps = {
resourceId: string;
resourceType: ResourceType;
isSecondaryBanner?: boolean;
};
export function ResourceProgressStats(props: ResourceProgressStatsProps) {
const { isSecondaryBanner = false } = props;
const [isSharing, setIsSharing] = useState(false);
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
const $currentRoadmap = useStore(currentRoadmap);
return (
<>
{isSharing && $canManageCurrentRoadmap && $currentRoadmap && (
<ShareOptionsModal
visibility={$currentRoadmap?.visibility}
teamId={$currentRoadmap?.teamId}
roadmapId={$currentRoadmap?._id!}
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
sharedTeamMemberIds={$currentRoadmap?.sharedTeamMemberIds || []}
onClose={() => setIsSharing(false)}
onShareSettingsUpdate={(settings) => {
currentRoadmap.set({
...$currentRoadmap,
...settings,
});
}}
/>
)}
<div
data-progress-nums-container=""
className={cn(
'striped-loader relative hidden items-center justify-between bg-white px-2 py-1.5 sm:flex',
{
'rounded-bl-md rounded-br-md': isSecondaryBanner,
'rounded-md': !isSecondaryBanner,
}
)}
>
<p
className="flex text-sm opacity-0 transition-opacity duration-300"
data-progress-nums=""
>
<span className="mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span data-progress-percentage="">0</span>% Done
</span>
<span className="itesm-center hidden md:flex">
<span>
<span data-progress-done="">0</span> completed
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span data-progress-learning="">0</span> in progress
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span data-progress-skipped="">0</span> skipped
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span data-progress-total="">0</span> Total
</span>
</span>
<span className="md:hidden">
<span data-progress-done="">0</span> of{' '}
<span data-progress-total="">0</span> Done
</span>
</p>
<div
className="flex items-center gap-3 opacity-0 transition-opacity duration-300"
data-progress-nums=""
>
<button
data-popup="progress-help"
className="flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black"
data-progress-nums=""
>
<HelpCircle className="h-3.5 w-3.5 stroke-[2.5px]" />
Track Progress
</button>
</div>
</div>
<div
data-progress-nums-container=""
className="striped-loader relative -mb-2 flex items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden"
>
<span
data-progress-nums=""
className="text-gray-500 opacity-0 transition-opacity duration-300"
>
<span data-progress-done="">0</span> of{' '}
<span data-progress-total="">0</span> Done
</span>
</div>
</>
);
}

View File

@@ -1,52 +0,0 @@
import { ShieldBan } from 'lucide-react';
import type { FetchError } from '../../lib/http';
type RestrictedPageProps = {
error: FetchError;
};
export function RestrictedPage(props: RestrictedPageProps) {
const { error } = props;
if (error.status === 404) {
return (
<ErrorMessage
icon={<ShieldBan className="h-16 w-16" />}
title="Roadmap not found"
message="The roadmap you are looking for does not exist or has been deleted."
/>
);
}
return (
<ErrorMessage
icon={<ShieldBan className="h-16 w-16" />}
title="Restricted Access"
message={error?.message}
/>
);
}
type ErrorMessageProps = {
title: string;
message: string;
icon: React.ReactNode;
};
function ErrorMessage(props: ErrorMessageProps) {
const { title, message, icon } = props;
return (
<div className="flex grow flex-col items-center justify-center">
{icon}
<h2 className="mt-4 text-2xl font-semibold">{title}</h2>
<p>{message || 'This roadmap is not available for public access.'}</p>
<a
href="/"
className="mt-4 font-medium underline underline-offset-2 hover:no-underline"
>
&larr; Go back to home
</a>
</div>
);
}

View File

@@ -1,85 +0,0 @@
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
type RoadmapActionButtonProps = {
onDelete?: () => void;
onCustomize?: () => void;
onUpdateSharing?: () => void;
};
export function RoadmapActionButton(props: RoadmapActionButtonProps) {
const { onDelete, onUpdateSharing, onCustomize } = props;
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
useOutsideClick(menuRef, () => {
setIsOpen(false);
});
return (
<div className="relative">
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center justify-center rounded-md bg-gray-500 py-1.5 pl-2 pr-2 text-xs font-medium text-white hover:bg-gray-600 sm:pl-1.5 sm:pr-3 sm:text-sm"
>
<MoreVertical className="mr-0 h-4 w-4 stroke-[2.5] sm:mr-1.5" />
<span className="hidden sm:inline">Actions</span>
</button>
{isOpen && (
<div
ref={menuRef}
className="align-right absolute right-0 top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
>
<ul>
{onUpdateSharing && (
<li>
<button
onClick={() => {
setIsOpen(false);
onUpdateSharing();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Lock size={14} className="mr-2" />
Sharing
</button>
</li>
)}
{onCustomize && (
<li>
<button
onClick={() => {
setIsOpen(false);
onCustomize();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Shapes size={14} className="mr-2" />
Customize
</button>
</li>
)}
{onDelete && (
<li>
<button
onClick={() => {
setIsOpen(false);
onDelete();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Trash2 size={14} className="mr-2" />
Delete
</button>
</li>
)}
</ul>
</div>
)}
</div>
);
}

View File

@@ -1,142 +0,0 @@
import { RoadmapHint } from './RoadmapHint';
import { useStore } from '@nanostores/react';
import { canManageCurrentRoadmap, currentRoadmap } from '../../stores/roadmap';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
import { useState } from 'react';
import { pageProgressMessage } from '../../stores/page';
import { httpDelete, httpPut } from '../../lib/http';
import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
import { useToast } from '../../hooks/use-toast';
import { RoadmapActionButton } from './RoadmapActionButton';
type RoadmapHeaderProps = {};
export function RoadmapHeader(props: RoadmapHeaderProps) {
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
const $currentRoadmap = useStore(currentRoadmap);
const { title, description, _id: roadmapId } = useStore(currentRoadmap) || {};
const [isSharing, setIsSharing] = useState(false);
const toast = useToast();
async function deleteResource() {
pageProgressMessage.set('Deleting roadmap');
const teamId = $currentRoadmap?.teamId;
const baseApiUrl = import.meta.env.PUBLIC_API_URL;
let error, response;
if (teamId) {
({ error, response } = await httpPut<TeamResourceConfig>(
`${baseApiUrl}/v1-delete-team-resource-config/${teamId}`,
{
resourceId: roadmapId,
resourceType: 'roadmap',
}
));
} else {
({ error, response } = await httpDelete<TeamResourceConfig>(
`${baseApiUrl}/v1-delete-roadmap/${roadmapId}`
));
}
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success('Roadmap removed');
if (!teamId) {
window.location.href = '/account/roadmaps';
} else {
window.location.href = `/team/roadmaps?t=${teamId}`;
}
}
return (
<div className="border-b">
<div className="container relative py-5 sm:py-12">
<div className="mb-3 mt-0 sm:mb-4">
<h1 className="text-2xl font-bold sm:mb-2 sm:text-4xl">{title}</h1>
<p className="mt-0.5 text-sm text-gray-500 sm:text-lg">
{description}
</p>
</div>
<div className="flex justify-between gap-2 sm:gap-0">
<div className="flex gap-1 sm:gap-2">
<a
href="/roadmaps"
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
aria-label="Back to All Roadmaps"
>
&larr;<span className="hidden sm:inline">&nbsp;All Roadmaps</span>
</a>
<button
data-guest-required
data-popup="login-popup"
className="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
aria-label="Subscribe for Updates"
>
<span className="ml-2">Subscribe</span>
</button>
</div>
{$canManageCurrentRoadmap && (
<div className="flex items-center gap-2">
{isSharing && $currentRoadmap && (
<ShareOptionsModal
visibility={$currentRoadmap?.visibility}
teamId={$currentRoadmap?.teamId}
roadmapId={$currentRoadmap?._id!}
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
sharedTeamMemberIds={
$currentRoadmap?.sharedTeamMemberIds || []
}
onClose={() => setIsSharing(false)}
onShareSettingsUpdate={(settings) => {
currentRoadmap.set({
...$currentRoadmap,
...settings,
});
}}
/>
)}
<RoadmapActionButton
onDelete={() => {
const confirmation = window.confirm(
'Are you sure you want to delete this roadmap?'
);
if (!confirmation) {
return;
}
deleteResource().finally(() => null);
}}
onCustomize={() => {
const editorLink = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${$currentRoadmap?._id}`;
window.open(editorLink, '_blank');
}}
onUpdateSharing={() => {
setIsSharing(true);
}}
/>
</div>
)}
</div>
<RoadmapHint
roadmapTitle={title!}
hasTNSBanner={false}
roadmapId={roadmapId!}
/>
</div>
</div>
);
}

View File

@@ -1,48 +0,0 @@
import { cn } from '../../lib/classname';
import { ResourceProgressStats } from './ResourceProgressStats';
type RoadmapHintProps = {
roadmapId: string;
roadmapTitle: string;
hasTNSBanner?: boolean;
tnsBannerLink?: string;
};
export function RoadmapHint(props: RoadmapHintProps) {
const {
roadmapTitle,
roadmapId,
hasTNSBanner = false,
tnsBannerLink = '',
} = props;
return (
<div
className={cn('mb-0 mt-4 rounded-md border-0 sm:mt-7 sm:border', {
'sm:-mb-[82px]': hasTNSBanner,
'sm:-mb-[65px]': !hasTNSBanner,
})}
>
{hasTNSBanner && (
<div className="hidden border-b bg-gray-100 px-2 py-1.5 sm:block">
<p className="text-sm">
Get the latest {roadmapTitle} news from our sister site{' '}
<a
href={tnsBannerLink}
target="_blank"
className="font-semibold underline"
>
TheNewStack.io
</a>
</p>
</div>
)}
<ResourceProgressStats
isSecondaryBanner={hasTNSBanner}
resourceId={roadmapId}
resourceType="roadmap"
/>
</div>
);
}

View File

@@ -1,134 +0,0 @@
import { useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import {
CreateRoadmapModal,
type RoadmapDocument,
} from './CreateRoadmap/CreateRoadmapModal';
import { PersonalRoadmapList } from './PersonalRoadmapList';
import { useToast } from '../../hooks/use-toast';
import { SharedRoadmapList } from './SharedRoadmapList';
import type { FriendshipStatus } from '../Befriend';
export type FriendUserType = {
id: string;
name: string;
avatar: string;
status: FriendshipStatus;
};
export type GetRoadmapListResponse = {
personalRoadmaps: (RoadmapDocument & {
topics: number;
})[];
sharedRoadmaps: (RoadmapDocument & {
topics: number;
creator: FriendUserType;
})[];
};
type TabType = {
label: string;
value: 'personal' | 'shared';
};
const tabTypes: TabType[] = [
{ label: 'Personal', value: 'personal' },
{ label: 'Shared by Friends', value: 'shared' },
];
export function RoadmapListPage() {
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [activeTab, setActiveTab] = useState<TabType['value']>('personal');
const [allRoadmaps, setAllRoadmaps] = useState<GetRoadmapListResponse>({
personalRoadmaps: [],
sharedRoadmaps: [],
});
async function loadRoadmapList() {
setIsLoading(true);
const { response, error } = await httpGet<GetRoadmapListResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-roadmap-list`
);
if (error || !response) {
console.error(error);
toast.error(error?.message || 'Something went wrong, please try again');
return;
}
setAllRoadmaps(
response! || {
personalRoadmaps: [],
sharedRoadmaps: [],
}
);
}
useEffect(() => {
loadRoadmapList().finally(() => {
setIsLoading(false);
pageProgressMessage.set('');
});
}, []);
if (isLoading) {
return null;
}
return (
<div>
{isCreatingRoadmap && (
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
)}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
{tabTypes.map((tab) => {
return (
<button
key={tab.value}
className={`relative flex items-center justify-center rounded-md border p-1 px-3 text-sm ${
activeTab === tab.value ? ' border-gray-400 bg-gray-200 ' : ''
} w-full sm:w-auto`}
onClick={() => setActiveTab(tab.value)}
>
{tab.label}
</button>
);
})}
</div>
<button
className={`relative flex w-full items-center justify-center rounded-md border p-1 px-3 text-sm sm:w-auto`}
onClick={() => setIsCreatingRoadmap(true)}
>
+ Create Roadmap
</button>
</div>
<div className="mt-4">
{activeTab === 'personal' && (
<PersonalRoadmapList
roadmaps={allRoadmaps?.personalRoadmaps}
setAllRoadmaps={setAllRoadmaps}
onDelete={(roadmapId) => {
setAllRoadmaps({
...allRoadmaps,
personalRoadmaps: allRoadmaps.personalRoadmaps.filter(
(r) => r._id !== roadmapId
),
});
}}
/>
)}
{activeTab === 'shared' && (
<SharedRoadmapList roadmaps={allRoadmaps?.sharedRoadmaps} />
)}
</div>
</div>
);
}

View File

@@ -1,53 +0,0 @@
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 > 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 .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;
}

View File

@@ -1,177 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Renderer } from '../../../renderer';
import './RoadmapRenderer.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 { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { EmptyRoadmap } from './EmptyRoadmap';
import { isLoggedIn } from '../../lib/jwt';
import { httpPost } from '../../lib/http';
type RoadmapRendererProps = {
roadmap: RoadmapDocument;
};
type RoadmapNodeDetails = {
nodeId: string;
nodeType: string;
targetGroup: SVGElement;
};
export function getNodeDetails(
svgElement: SVGElement
): RoadmapNodeDetails | null {
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
const nodeId = targetGroup?.dataset?.nodeId;
const nodeType = targetGroup?.dataset?.type;
if (!nodeId || !nodeType) return null;
return { nodeId, nodeType, targetGroup };
}
export const allowedClickableNodeTypes = [
'topic',
'subtopic',
'button',
'link-item',
];
export function RoadmapRenderer(props: RoadmapRendererProps) {
const { roadmap } = props;
const roadmapRef = useRef<HTMLDivElement>(null);
const roadmapId = roadmap._id!;
const toast = useToast();
const [hideRenderer, setHideRenderer] = useState(false);
async function updateTopicStatus(
topicId: string,
newStatus: ResourceProgressType
) {
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId: roadmapId,
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 } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedClickableNodeTypes.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();
updateTopicStatus(
nodeId,
isCurrentStatusLearning ? 'pending' : 'learning'
);
return;
} else if (e.altKey) {
e.preventDefault();
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
return;
}
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
topicId: nodeId,
resourceId: roadmap?._id,
resourceType: 'roadmap',
isCustomResource: true,
},
})
);
}, []);
const handleSvgRightClick = useCallback((e: MouseEvent) => {
e.preventDefault();
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
return;
if (nodeType === 'button' || nodeType === 'link-item') {
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 (
<div className="flex grow bg-gray-50 pb-8 pt-4 sm:pt-12">
<div className="container !max-w-[1000px]">
<Renderer
ref={roadmapRef}
roadmap={{ nodes: roadmap?.nodes!, edges: roadmap?.edges! }}
onRendered={() => {
renderResourceProgress('roadmap', roadmapId).then(() => {
if (roadmap?.nodes?.length === 0) {
setHideRenderer(true);
roadmapRef?.current?.classList.add('hidden');
}
});
}}
/>
{hideRenderer && <EmptyRoadmap />}
</div>
</div>
);
}

View File

@@ -1,162 +0,0 @@
import { useState } from 'react';
import { useStore } from '@nanostores/react';
import { Check, Copy, Loader2 } from 'lucide-react';
import { Modal } from '../Modal';
import type { AllowedRoadmapVisibility } from './CreateRoadmap/CreateRoadmapModal';
import { cn } from '../../lib/classname';
import { httpPatch } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { useCopyText } from '../../hooks/use-copy-text';
import { currentRoadmap, isCurrentRoadmapPersonal } from '../../stores/roadmap';
type ShareRoadmapModalProps = {
onClose: () => void;
};
export const allowedVisibilityLabels: {
id: AllowedRoadmapVisibility;
label: string;
}[] = [
{
id: 'me',
label: 'Only visible to me',
},
{
id: 'public',
label: 'Anyone with the link',
},
{
id: 'team',
label: 'Visible to team members',
},
{
id: 'friends',
label: 'Only friends can view',
},
];
export function ShareRoadmapModal(props: ShareRoadmapModalProps) {
const { onClose } = props;
const toast = useToast();
const $currentRoadmap = useStore(currentRoadmap);
const $isCurrentRoadmapPersonal = useStore(isCurrentRoadmapPersonal);
const roadmapId = $currentRoadmap?._id!;
const { copyText, isCopied } = useCopyText();
const [visibility, setVisibility] = useState($currentRoadmap?.visibility);
const [isLoading, setIsLoading] = useState(false);
async function updateVisibility(newVisibility: AllowedRoadmapVisibility) {
setIsLoading(true);
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-roadmap-visibility/${
$currentRoadmap?._id
}`,
{
visibility: newVisibility,
}
);
if (error) {
console.error(error);
toast.error(error?.message || 'Something went wrong, please try again');
setIsLoading(false);
return;
}
setIsLoading(false);
toast.success('Visibility updated');
setVisibility(newVisibility);
currentRoadmap.set({
...$currentRoadmap!,
visibility: newVisibility,
});
}
function handleCopy() {
const isDev = import.meta.env.DEV;
const url = new URL(
isDev ? 'http://localhost:3000/r' : 'https://roadmap.sh/r'
);
url.searchParams.set('id', roadmapId);
copyText(url.toString());
}
return (
<Modal onClose={onClose}>
<div className="p-4 pb-0">
<h1 className="text-lg font-medium leading-5 text-gray-900">
Updating {$currentRoadmap?.title}
</h1>
</div>
<ul className="mt-4 border-t">
{allowedVisibilityLabels.map((v) => {
if (v.id === 'team' && $isCurrentRoadmapPersonal) {
return null;
} else if (v.id === 'friends' && !$isCurrentRoadmapPersonal) {
return null;
}
return (
<li key={v.id}>
<button
disabled={v.id === visibility || isLoading}
key={v.id}
className={cn(
'relative flex w-full items-center border-b p-2.5 px-4 text-sm text-gray-700 hover:bg-gray-200 hover:text-gray-900 disabled:cursor-not-allowed',
v.id === visibility &&
'bg-gray-900 text-white hover:bg-gray-900 hover:text-white'
)}
onClick={() => updateVisibility(v.id)}
>
{v.label}
{v.id === visibility && (
<span className="absolute bottom-0 right-0 top-0 flex w-8 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-green-500" />
</span>
)}
</button>
</li>
);
})}
</ul>
<div className="flex items-center justify-between p-4">
<button
disabled={isLoading}
className="flex h-9 items-center rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-gray-300 focus:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-70"
onClick={onClose}
>
{isLoading ? (
<>
<Loader2 size={14} className="mr-2 animate-spin stroke-[2.5]" />
Saving
</>
) : (
'Cancel'
)}
</button>
<button
className="flex h-9 items-center justify-center rounded-md border border-transparent bg-gray-900 px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800"
onClick={handleCopy}
>
{isCopied ? (
<>
<Check size={14} className="mr-2 stroke-[2.5]" />
Copied
</>
) : (
<>
<Copy size={14} className="mr-2 stroke-[2.5]" />
Copy Link
</>
)}
</button>
</div>
</Modal>
);
}

View File

@@ -1,118 +0,0 @@
import { ExternalLinkIcon, Map, Plus } from 'lucide-react';
import RoadmapIcon from '../../icons/roadmap.svg';
import type { GetRoadmapListResponse } from './RoadmapListPage';
type GroupByCreator = {
creator: GetRoadmapListResponse['sharedRoadmaps'][number]['creator'];
roadmaps: GetRoadmapListResponse['sharedRoadmaps'];
};
type SharedRoadmapListProps = {
roadmaps: GetRoadmapListResponse['sharedRoadmaps'];
};
export function SharedRoadmapList(props: SharedRoadmapListProps) {
const { roadmaps: sharedRoadmaps } = props;
const allUniqueCreatorIds = new Set(
sharedRoadmaps.map((roadmap) => roadmap.creator.id)
);
const groupByCreator: GroupByCreator[] = [];
for (const creatorId of allUniqueCreatorIds) {
const creator = sharedRoadmaps.find(
(roadmap) => roadmap.creator.id === creatorId
)?.creator;
if (!creator) {
continue;
}
groupByCreator.push({
creator,
roadmaps: sharedRoadmaps.filter(
(roadmap) => roadmap.creator.id === creatorId
),
});
}
if (sharedRoadmaps.length === 0) {
return (
<div className="flex flex-col items-center p-4 py-20">
<Map className="mb-4 h-24 w-24 opacity-10" />
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
<p className="text-base text-gray-500">
Roadmaps from your friends will appear here
</p>
</div>
);
}
return (
<div>
<div className="mb-3 flex items-center justify-between">
<span className={'text-sm text-gray-400'}>
{sharedRoadmaps.length} shared roadmap(s)
</span>
</div>
<div>
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{groupByCreator.map((group) => {
const creator = group.creator;
return (
<li
key={creator.id}
className="flex flex-col items-start overflow-hidden rounded-md border border-gray-300"
>
<div className="relative flex w-full items-center gap-3 p-3">
<img
src={
creator.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
creator.avatar
}`
: '/images/default-avatar.png'
}
alt={creator.name || ''}
className="h-8 w-8 rounded-full"
/>
<div>
<h3 className="truncate font-medium">{creator.name}</h3>
<p className="truncate text-sm text-gray-500">
{group?.roadmaps?.length || 0} shared roadmap(s)
</p>
</div>
</div>
<ul className="w-full">
{group?.roadmaps?.map((roadmap) => {
return (
<li
key={roadmap._id}
className="relative flex w-full border-t"
>
<a
href={`/r?id=${roadmap._id}`}
className="group inline-grid w-full grid-cols-[auto,16px] items-center justify-between gap-2 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-black"
target={'_blank'}
>
<span className="w-full truncate">
{roadmap.title}
</span>
<ExternalLinkIcon
size={16}
className="opacity-20 transition-opacity group-hover:opacity-100"
/>
</a>
</li>
);
})}
</ul>
</li>
);
})}
</ul>
</div>
</div>
);
}

View File

@@ -1,28 +0,0 @@
export function SkeletonRoadmapHeader() {
return (
<div className="border-b">
<div className="container relative py-5 sm:py-12">
<div className="mb-3 mt-0 sm:mb-4">
<div className="h-8 w-1/2 animate-pulse rounded-md bg-gray-300 sm:mb-2 sm:h-10" />
<div className="mt-0.5 h-5 w-1/3 animate-pulse rounded-md bg-gray-200 sm:h-7" />
</div>
<div className="flex justify-between gap-2 sm:gap-0">
<div className="h-7 w-[35.04px] sm:w-32 animate-pulse rounded-md bg-gray-300 sm:h-8" />
<div className="h-7 w-[32px] sm:w-[89.73px] animate-pulse rounded-md bg-gray-300 sm:h-8" />
</div>
<div className="mb-0 mt-4 rounded-md border-0 sm:-mb-[65px] sm:mt-7 sm:border">
<div
data-progress-nums-container
className="striped-loader relative hidden h-8 items-center justify-between rounded-md bg-white sm:flex"
/>
<div
data-progress-nums-container
className="striped-loader relative -mb-2 flex h-[34px] items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden"
/>
</div>
</div>
</div>
);
}

View File

@@ -1,20 +1,13 @@
---
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
import FeaturedItem, { type FeaturedItemType } from './FeaturedItem.astro';
import FeaturedItem, { FeaturedItemType } from './FeaturedItem.astro';
export interface Props {
featuredItems: FeaturedItemType[];
heading: string;
showCreateRoadmap?: boolean;
allowBookmark?: boolean;
}
const {
featuredItems,
heading,
showCreateRoadmap,
allowBookmark = true,
} = Astro.props;
const { featuredItems, heading, allowBookmark = true } = Astro.props;
---
<div class='relative border-b border-b-[#1e293c] py-10 sm:py-14'>
@@ -39,19 +32,6 @@ const {
</li>
))
}
{
showCreateRoadmap && (
<li>
<CreateRoadmapButton
client:load
className='min-h-[54px]'
type={
heading.toLowerCase().indexOf('role') > -1 ? 'role' : 'skill'
}
/>
</li>
)
}
</ul>
</div>
</div>

View File

@@ -10,10 +10,7 @@ import { AddUserIcon } from '../ReactIcons/AddUserIcon';
type FriendProgressItemProps = {
friend: ListFriendsResponse[0];
onShowResourceProgress: (
resourceId: string,
isCustomResource?: boolean
) => void;
onShowResourceProgress: (resourceId: string) => void;
onReload: () => void;
};
@@ -55,7 +52,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
onReload();
}
const roadmaps = (friend?.roadmaps || []).sort((a, b) => {
const roadmaps = (friend.roadmaps || []).sort((a, b) => {
return b.done - a.done;
});
@@ -89,12 +86,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
{(showAll ? roadmaps : roadmaps.slice(0, 4)).map((progress) => {
return (
<button
onClick={() =>
onShowResourceProgress(
progress.resourceId,
progress.isCustomResource
)
}
onClick={() => onShowResourceProgress(progress.resourceId)}
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
key={progress.resourceId}
>

View File

@@ -16,7 +16,6 @@ type FriendResourceProgress = {
title: string;
resourceId: string;
resourceType: string;
isCustomResource: boolean;
learning: number;
skipped: number;
done: number;
@@ -53,7 +52,6 @@ export function FriendsPage() {
const [showFriendProgress, setShowFriendProgress] = useState<{
resourceId: string;
friend: ListFriendsResponse[0];
isCustomResource?: boolean;
}>();
const [isLoading, setIsLoading] = useState(true);
@@ -122,7 +120,6 @@ export function FriendsPage() {
resourceId={showFriendProgress.resourceId}
resourceType={'roadmap'}
onClose={() => setShowFriendProgress(undefined)}
isCustomResource={showFriendProgress.isCustomResource}
/>
)}
@@ -170,11 +167,10 @@ export function FriendsPage() {
{filteredFriends.map((friend) => (
<FriendProgressItem
friend={friend}
onShowResourceProgress={(resourceId, isCustomResource) => {
onShowResourceProgress={(resourceId) => {
setShowFriendProgress({
resourceId,
friend,
isCustomResource,
});
}}
key={friend.userId}

View File

@@ -29,7 +29,12 @@ export function SidebarFriendsCounter() {
const pendingCount = friendCounts?.receivedCount || 0;
if (!pendingCount) {
return null;
return (
<span className="relative mr-1 flex items-center">
<span className="relative rounded-full bg-gray-200 p-1 text-xs" />
<span className="absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs" />
</span>
);
}
return (

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { EmptyProgress } from './EmptyProgress';
import { httpGet } from '../../lib/http';
import { HeroRoadmaps } from './HeroRoadmaps';
import { isLoggedIn } from '../../lib/jwt';
import {isLoggedIn} from "../../lib/jwt";
export type UserProgressResponse = {
resourceId: string;
@@ -14,7 +14,6 @@ export type UserProgressResponse = {
skipped: number;
total: number;
updatedAt: Date;
isCustomResource: boolean;
}[];
function renderProgress(progressList: UserProgressResponse) {
@@ -49,8 +48,6 @@ function renderProgress(progressList: UserProgressResponse) {
});
}
type ProgressResponse = UserProgressResponse;
export function FavoriteRoadmaps() {
const isAuthenticated = isLoggedIn();
if (!isAuthenticated) {
@@ -59,7 +56,7 @@ export function FavoriteRoadmaps() {
const [isPreparing, setIsPreparing] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [progress, setProgress] = useState<ProgressResponse>([]);
const [progress, setProgress] = useState<UserProgressResponse>([]);
const [containerOpacity, setContainerOpacity] = useState(0);
function showProgressContainer() {
@@ -82,9 +79,10 @@ export function FavoriteRoadmaps() {
async function loadProgress() {
setIsLoading(true);
const { response: progressList, error } = await httpGet<ProgressResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`
);
const { response: progressList, error } =
await httpGet<UserProgressResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
);
if (error || !progressList) {
return;
@@ -113,9 +111,7 @@ export function FavoriteRoadmaps() {
return null;
}
const hasProgress = progress?.length > 0;
const customRoadmaps = progress?.filter((p) => p.isCustomResource);
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
const hasProgress = progress.length > 0;
return (
<div
@@ -124,14 +120,9 @@ export function FavoriteRoadmaps() {
}`}
>
<div className="container min-h-full">
{!isLoading && progress?.length == 0 && <EmptyProgress />}
{hasProgress && (
<HeroRoadmaps
showCustomRoadmaps={true}
customRoadmaps={customRoadmaps}
progress={defaultRoadmaps}
isLoading={isLoading}
/>
{!isLoading && progress.length == 0 && <EmptyProgress />}
{progress.length > 0 && (
<HeroRoadmaps customRoadmaps={[]} progress={progress} isLoading={isLoading} />
)}
</div>
</div>

View File

@@ -4,9 +4,6 @@ import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
import { Spinner } from '../ReactIcons/Spinner';
import type { ResourceType } from '../../lib/resource-progress';
import { MapIcon } from 'lucide-react';
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { useState } from 'react';
type ProgressRoadmapProps = {
url: string;
@@ -76,20 +73,21 @@ export function HeroTitle(props: ProgressTitleProps) {
type ProgressListProps = {
progress: UserProgressResponse;
customRoadmaps: UserProgressResponse;
showCustomRoadmaps?: boolean;
customRoadmaps: any[]; // @fixme implement this
isLoading?: boolean;
};
export function HeroRoadmaps(props: ProgressListProps) {
const { progress, isLoading = false, customRoadmaps } = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const {
progress,
isLoading = false,
customRoadmaps = [{} /* @fixme implement this */],
showCustomRoadmaps = false,
} = props;
return (
<div className="relative pb-12 pt-4 sm:pt-7">
{isCreatingRoadmap && (
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
)}
{
<HeroTitle
icon={
@@ -120,50 +118,38 @@ export function HeroRoadmaps(props: ProgressListProps) {
))}
</div>
<div className="mt-5">
{
<HeroTitle
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
title="Your custom roadmaps"
/>
}
{showCustomRoadmaps && (
<div className="mt-5">
{
<HeroTitle
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
title="Your custom roadmaps"
/>
}
{customRoadmaps.length === 0 && (
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
You haven't created any custom roadmaps yet.{' '}
<button
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
onClick={() => setIsCreatingRoadmap(true)}
>
Create one!
</button>
</p>
)}
{customRoadmaps.length === 0 && (
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
You haven't created any custom roadmaps yet.{' '}
<button className="text-gray-500 underline underline-offset-2 hover:text-gray-400">
Create one!
</button>
</p>
)}
{customRoadmaps.length > 0 && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{customRoadmaps.map((customRoadmap) => {
return (
<HeroRoadmap
key={customRoadmap.resourceId}
resourceId={customRoadmap.resourceId}
resourceType={'roadmap'}
resourceTitle={customRoadmap.resourceTitle}
percentageDone={
((customRoadmap.skipped + customRoadmap.done) /
customRoadmap.total) *
100
}
url={`/r?id=${customRoadmap.resourceId}`}
allowFavorite={false}
/>
);
})}
<CreateRoadmapButton />
{customRoadmaps.map((customRoadmap) => (
<HeroRoadmap
resourceId={'343434'}
resourceType={'roadmap'}
resourceTitle={'Frontend Roadmap Revised'}
percentageDone={50}
url={`/r?${'34343434'}`}
allowFavorite={false}
/>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,46 +0,0 @@
import { type ReactNode, useRef } from 'react';
import { useOutsideClick } from '../hooks/use-outside-click';
import { useKeydown } from '../hooks/use-keydown';
import { cn } from '../lib/classname';
type ModalProps = {
onClose: () => void;
children: ReactNode;
bodyClassName?: string;
wrapperClassName?: string;
};
export function Modal(props: ModalProps) {
const { onClose, children, bodyClassName, wrapperClassName } = props;
const popupBodyEl = useRef<HTMLDivElement>(null);
useKeydown('Escape', () => {
onClose();
});
useOutsideClick(popupBodyEl, () => {
onClose();
});
return (
<div className="popup fixed left-0 right-0 top-0 z-[99] flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div
className={cn(
'relative h-full w-full max-w-md p-4 md:h-auto',
wrapperClassName
)}
>
<div
ref={popupBodyEl}
className={cn(
'popup-body relative h-full rounded-lg bg-white shadow',
bodyClassName
)}
>
{children}
</div>
</div>
</div>
);
}

View File

@@ -4,12 +4,12 @@ import Icon from '../AstroIcon.astro';
<div class='relative hidden' data-auth-required>
<button
class='flex h-8 w-38 items-center justify-center 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'
class='flex h-8 w-28 items-center justify-center 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'
type='button'
data-account-button
>
<span class='inline-flex items-center gap-1.5'>
Account <span class="text-gray-300">/</span> Teams
Account
<Icon
icon='chevron-down'
class='relative top-[0.5px] h-3 w-3 stroke-[3px]'

View File

@@ -1,52 +0,0 @@
import { useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt';
import { AccountDropdownList } from './AccountDropdownList';
import { DropdownTeamList } from './DropdownTeamList';
import { useOutsideClick } from '../../hooks/use-outside-click';
export function AccountDropdown() {
const dropdownRef = useRef(null);
const [showDropdown, setShowDropdown] = useState(false);
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
useOutsideClick(dropdownRef, () => {
setShowDropdown(false);
setIsTeamsOpen(false);
});
if (!isLoggedIn()) {
return null;
}
return (
<div className="relative z-50 animate-fade-in">
<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&nbsp;<span className="text-gray-300">/</span>&nbsp;Teams
</span>
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
</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 setIsTeamsOpen={setIsTeamsOpen} />
)}
</div>
)}
</div>
);
}

View File

@@ -1,49 +0,0 @@
import { ChevronRight } from 'lucide-react';
import { logout } from './navigation';
type AccountDropdownListProps = {
setIsTeamsOpen: (isOpen: boolean) => void;
};
export function AccountDropdownList(props: AccountDropdownListProps) {
const { setIsTeamsOpen } = props;
return (
<ul>
<li className="px-1">
<a
href="/account"
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
Profile
</a>
</li>
<li className="px-1">
<a
href="/account/friends"
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
Friends
</a>
</li>
<li className="px-1">
<button
className="group flex w-full items-center justify-between rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
onClick={() => setIsTeamsOpen(true)}
>
Teams
<ChevronRight className="h-4 w-4 shrink-0 stroke-[2.5px] text-slate-400 group-hover:text-white" />
</button>
</li>
<li className="px-1">
<button
className="block w-full rounded pl-4 pr-2 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700"
type="button"
onClick={logout}
>
Logout
</button>
</li>
</ul>
);
}

View File

@@ -1,110 +0,0 @@
import { ChevronLeft, Loader2, Plus, Users } from 'lucide-react';
import { $teamList } from '../../stores/team';
import { httpGet } from '../../lib/http';
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
import { useToast } from '../../hooks/use-toast';
import { useStore } from '@nanostores/react';
import { useEffect, useState } from 'react';
import { Spinner } from '../ReactIcons/Spinner';
type DropdownTeamListProps = {
setIsTeamsOpen: (isOpen: boolean) => void;
};
export function DropdownTeamList(props: DropdownTeamListProps) {
const { setIsTeamsOpen } = props;
const toast = useToast();
const teamList = useStore($teamList);
const [isLoading, setIsLoading] = useState(true);
async function getAllTeams() {
if (teamList.length > 0) {
return;
}
setIsLoading(true);
const { response, error } = await httpGet<TeamListResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
$teamList.set(response);
}
useEffect(() => {
getAllTeams().finally(() => setIsLoading(false));
}, []);
const loadingIndicator = isLoading && (
<div className="mt-2 flex animate-pulse flex-col gap-1 px-1 text-center">
<div className="h-[35px] rounded-md bg-gray-700"></div>
<div className="h-[35px] rounded-md bg-gray-700"></div>
<div className="h-[35px] rounded-md bg-gray-700"></div>
</div>
);
return (
<>
<div className="flex items-center justify-between px-2">
<button
className="mt-1 flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-slate-50/10 hover:text-slate-50"
onClick={() => setIsTeamsOpen(false)}
>
<ChevronLeft className="h-4 w-4 stroke-[2.5px]" />
</button>
<a
className="mt-1 flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-slate-50/10 hover:text-slate-50"
href="/team/new"
>
<Plus className="h-4 w-4 stroke-[2.5px]" />
</a>
</div>
{loadingIndicator}
{!isLoading && (
<ul className="mt-2">
{teamList?.map((team) => {
let pageLink = '';
if (team.status === 'invited') {
pageLink = `/respond-invite?i=${team.memberId}`;
} else if (team.status === 'joined') {
pageLink = `/team/progress?t=${team._id}`;
}
return (
<li key={team._id} className="px-1">
<a
href={pageLink}
className="block truncate rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
{team.name}
</a>
</li>
);
})}
{teamList.length === 0 && !isLoading && (
<li className="mt-2 px-1 text-center">
<p className="block rounded px-4 py-2 text-sm font-medium text-slate-500">
<Users className="mx-auto mb-2 h-7 w-7 text-slate-600" />
No teams found.{' '}
<a
className="font-medium text-slate-400 underline underline-offset-2 hover:text-slate-300"
href="/team/new"
>
Create a team
</a>
.
</p>
</li>
)}
</ul>
)}
</>
);
}

View File

@@ -1,6 +1,6 @@
---
import Icon from '../AstroIcon.astro';
import { AccountDropdown } from './AccountDropdown';
import AccountDropdown from './AccountDropdown.astro';
---
<div class='bg-slate-900 py-5 text-white sm:py-8'>
@@ -24,8 +24,7 @@ import { AccountDropdown } from './AccountDropdown';
>
</li>
<li class='hidden lg:inline'>
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a
>
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a>
</li>
<li class='hidden lg:inline'>
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
@@ -45,7 +44,7 @@ import { AccountDropdown } from './AccountDropdown';
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
</li>
<li>
<AccountDropdown client:only="react" />
<AccountDropdown />
<a
data-guest-required
@@ -109,11 +108,6 @@ import { AccountDropdown } from './AccountDropdown';
Account
</a>
</li>
<li data-auth-required class='hidden'>
<a href='/team' class='text-xl hover:text-blue-300 md:text-lg'>
Teams
</a>
</li>
<li data-auth-required class='hidden'>
<button
data-logout-button

View File

@@ -1,64 +0,0 @@
import { CheckCircle, Copy } from 'lucide-react';
import { useCopyText } from '../../hooks/use-copy-text';
import { cn } from '../../lib/classname';
type CopyRoadmapLinkProps = {
roadmapId: string;
onClose: () => void;
};
export function CopyRoadmapLink(props: CopyRoadmapLinkProps) {
const { roadmapId, onClose } = props;
const shareLink = `${
import.meta.env.PUBLIC_ROADMAP_WEB_URL
}/r?id=${roadmapId}`;
const { copyText, isCopied } = useCopyText();
return (
<div className="flex grow flex-col justify-center">
<div className="mt-5 flex grow flex-col items-center justify-center gap-1.5">
<CheckCircle className="h-14 w-14 text-green-500" />
<h3 className="text-xl font-medium">Sharing Settings Updated</h3>
</div>
<input
type="text"
className="mt-6 w-full rounded-md border bg-gray-50 p-2 px-2.5 text-gray-700 focus:outline-none"
value={shareLink}
readOnly
onClick={(e) => {
e.currentTarget.select();
copyText(shareLink);
}}
/>
<p className="mt-1 text-sm text-gray-400">
You can share the above link with anyone who has access
</p>
<div className="mt-4 flex flex-col items-center justify-end gap-2">
<button
className={cn(
'flex w-full items-center justify-center gap-1.5 rounded bg-black px-4 py-2.5 text-sm font-medium text-white hover:opacity-80',
isCopied && 'bg-green-300 text-green-800'
)}
disabled={isCopied}
onClick={() => {
copyText(shareLink);
}}
>
<Copy className="h-3.5 w-3.5 stroke-[2.5]" />
{isCopied ? 'Copied' : 'Copy'}
</button>
<button
className={cn(
'flex w-full items-center justify-center gap-1.5 rounded border border-black px-4 py-2 text-sm font-medium hover:bg-gray-100'
)}
onClick={onClose}
>
Close
</button>
</div>
</div>
);
}

View File

@@ -1,160 +0,0 @@
import { useEffect, useState } from 'react';
import { useToast } from '../../hooks/use-toast';
import { UserItem } from './UserItem';
import { Users2 } from 'lucide-react';
import {httpGet} from "../../lib/http";
export type FriendshipStatus =
| 'none'
| 'sent'
| 'received'
| 'accepted'
| 'rejected'
| 'got_rejected';
type FriendResourceProgress = {
updatedAt: string;
title: string;
resourceId: string;
resourceType: string;
learning: number;
skipped: number;
done: number;
total: number;
};
export type ListFriendsResponse = {
userId: string;
name: string;
email: string;
avatar: string;
status: FriendshipStatus;
roadmaps: FriendResourceProgress[];
bestPractices: FriendResourceProgress[];
}[];
type ShareFriendListProps = {
setFriends: (friends: ListFriendsResponse) => void;
friends: ListFriendsResponse;
sharedFriendIds: string[];
setSharedFriendIds: (friendIds: string[]) => void;
};
export function ShareFriendList(props: ShareFriendListProps) {
const { setFriends, friends, sharedFriendIds, setSharedFriendIds } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
async function loadFriends() {
if (friends.length > 0) {
return;
}
setIsLoading(true);
const { response, error } = await httpGet<ListFriendsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
setFriends(response.filter((friend) => friend.status === 'accepted'));
}
useEffect(() => {
loadFriends().finally(() => {
setIsLoading(false);
});
}, []);
const loadingFriends = isLoading && (
<ul className="mt-2 grid grid-cols-3 gap-1.5">
{[...Array(3)].map((_, idx) => (
<li
key={idx}
className="flex animate-pulse items-center gap-2.5 rounded-md border p-2"
>
<div className="relative top-[1px] h-10 w-10 shrink-0 rounded-full bg-gray-200" />
<div className="inline-grid w-full">
<div className="h-5 w-2/4 rounded bg-gray-200" />
<div className="mt-1 h-5 w-3/4 rounded bg-gray-200" />
</div>
</li>
))}
</ul>
);
return (
<>
{(friends.length > 0 || isLoading) && (
<div className="flex items-center justify-between gap-2">
<p className="text-sm">Select Friends to share the roadmap with</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={sharedFriendIds.length === friends.length}
onChange={(e) => {
if (e.target.checked) {
setSharedFriendIds(friends.map((f) => f.userId));
} else {
setSharedFriendIds([]);
}
}}
/>
<span className="text-sm">Select all</span>
</label>
</div>
)}
{loadingFriends}
{friends.length > 0 && !isLoading && (
<ul className="mt-2 grid grid-cols-3 gap-1.5">
{friends.map((friend) => {
const isSelected = sharedFriendIds?.includes(friend.userId);
return (
<li key={friend.userId}>
<UserItem
user={{
name: friend.name,
avatar: friend.avatar,
email: friend.email,
}}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
setSharedFriendIds(
sharedFriendIds.filter((id) => id !== friend.userId)
);
} else {
setSharedFriendIds([...sharedFriendIds, friend.userId]);
}
}}
/>
</li>
);
})}
</ul>
)}
{friends.length === 0 && !isLoading && (
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
<Users2 className="mb-3 h-10 w-10 text-gray-300" />
<p className="font-semibold text-gray-500">
You do not have any friends yet. <br />{' '}
<a
target="_blank"
className="underline underline-offset-2"
href={`${import.meta.env.PUBLIC_ROADMAP_WEB_URL}/account/friends`}
>
Invite your friends to share roadmaps with.
</a>
</p>
</div>
)}
</>
);
}

View File

@@ -1,311 +0,0 @@
import { type ReactNode, useCallback, useState } from 'react';
import { Globe2, Loader2, Lock } from 'lucide-react';
import { type ListFriendsResponse, ShareFriendList } from './ShareFriendList';
import { TransferToTeamList } from './TransferToTeamList';
import { ShareOptionTabs } from './ShareOptionsTab';
import {
ShareTeamMemberList,
type TeamMemberList,
} from './ShareTeamMemberList';
import { CopyRoadmapLink } from './CopyRoadmapLink';
import { useToast } from '../../hooks/use-toast';
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { httpPatch } from '../../lib/http';
import { Modal } from '../Modal';
import { cn } from '../../lib/classname';
import type { UserTeamItem } from '../TeamDropdown/TeamDropdown';
export type OnShareSettingsUpdate = (options: {
visibility: AllowedRoadmapVisibility;
sharedTeamMemberIds: string[];
sharedFriendIds: string[];
}) => void;
type ShareOptionsModalProps = {
onClose: () => void;
visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[];
sharedTeamMemberIds?: string[];
teamId?: string;
roadmapId?: string;
onShareSettingsUpdate: OnShareSettingsUpdate;
};
export function ShareOptionsModal(props: ShareOptionsModalProps) {
const {
roadmapId,
onClose,
visibility: defaultVisibility,
sharedTeamMemberIds: defaultSharedMemberIds = [],
sharedFriendIds: defaultSharedFriendIds = [],
teamId,
onShareSettingsUpdate,
} = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
const [friends, setFriends] = useState<ListFriendsResponse>([]);
const [teams, setTeams] = useState<UserTeamItem[]>([]);
const [members, setMembers] = useState<TeamMemberList[]>([]);
const [visibility, setVisibility] = useState(defaultVisibility);
const [sharedTeamMemberIds, setSharedTeamMemberIds] = useState<string[]>(
defaultSharedMemberIds
);
const [sharedFriendIds, setSharedFriendIds] = useState<string[]>(
defaultSharedFriendIds
);
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) {
isUpdateDisabled = true;
// Disable update button if there are no members to share with
} else if (
visibility === 'team' &&
teamId &&
sharedTeamMemberIds.length === 0
) {
isUpdateDisabled = true;
}
const handleShareChange: OnShareSettingsUpdate = async ({
sharedFriendIds,
visibility,
sharedTeamMemberIds,
}) => {
setIsLoading(true);
if (visibility === 'friends' && sharedFriendIds.length === 0) {
toast.error('Please select at least one friend');
return;
} else if (
visibility === 'team' &&
teamId &&
sharedTeamMemberIds.length === 0
) {
toast.error('Please select at least one member');
return;
}
const { response, error } = await httpPatch(
`${
import.meta.env.PUBLIC_API_URL
}/v1-update-roadmap-visibility/${roadmapId}`,
{
visibility,
sharedFriendIds,
sharedTeamMemberIds,
}
);
if (error) {
toast.error(error?.message || 'Something went wrong, please try again');
return;
}
setIsLoading(false);
setIsSettingsUpdated(true);
onShareSettingsUpdate({ sharedFriendIds, visibility, sharedTeamMemberIds });
};
const handleTransferToTeam = useCallback(
async (teamId: string) => {
if (!roadmapId) {
return;
}
setIsLoading(true);
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-transfer-roadmap/${roadmapId}`,
{
teamId,
}
);
if (error) {
setIsLoading(false);
toast.error(error?.message || 'Something went wrong, please try again');
return;
}
window.location.reload();
},
[roadmapId]
);
if (isSettingsUpdated) {
return (
<Modal
onClose={onClose}
wrapperClassName="max-w-lg"
bodyClassName="p-4 flex flex-col"
>
<CopyRoadmapLink roadmapId={roadmapId!} onClose={onClose} />
</Modal>
);
}
return (
<Modal
onClose={() => {
if (isLoading) {
return;
}
onClose();
}}
wrapperClassName="max-w-3xl"
bodyClassName="p-4 flex flex-col min-h-[400px]"
>
<div className="mb-4">
<h3 className="mb-1 text-xl font-semibold">Update Sharing Settings</h3>
<p className="text-sm text-gray-500">
Pick and modify who can access this roadmap.
</p>
</div>
<ShareOptionTabs
visibility={visibility}
setVisibility={setVisibility}
teamId={teamId}
onChange={(visibility) => {
setSelectedTeamId(null);
if (['me', 'public'].includes(visibility)) {
setSharedTeamMemberIds([]);
setSharedFriendIds([]);
} else if (visibility === 'friends') {
setSharedFriendIds(
defaultSharedFriendIds.length > 0 ? defaultSharedFriendIds : []
);
} else if (visibility === 'team' && teamId) {
setSharedTeamMemberIds(
defaultSharedMemberIds?.length > 0 ? defaultSharedMemberIds : []
);
setSharedFriendIds([]);
} else {
setSharedFriendIds([]);
setSharedTeamMemberIds([]);
}
}}
/>
<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>
)}
{/* For Personal Roadmap */}
{visibility === 'friends' && (
<ShareFriendList
friends={friends}
setFriends={setFriends}
sharedFriendIds={sharedFriendIds}
setSharedFriendIds={setSharedFriendIds}
/>
)}
{canTransferRoadmap && (
<TransferToTeamList
teams={teams}
setTeams={setTeams}
selectedTeamId={selectedTeamId}
setSelectedTeamId={setSelectedTeamId}
/>
)}
{/* For Team Roadmap */}
{visibility === 'team' && teamId && (
<ShareTeamMemberList
teamId={teamId}
sharedTeamMemberIds={sharedTeamMemberIds}
setSharedTeamMemberIds={setSharedTeamMemberIds}
members={members}
setMembers={setMembers}
/>
)}
</div>
<div className="mt-2 flex items-center justify-between gap-1.5">
<button
className="flex items-center justify-center gap-1.5 rounded-md border px-3.5 py-1.5 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-75"
disabled={isLoading}
onClick={onClose}
>
Close
</button>
{canTransferRoadmap ? (
<UpdateAction
disabled={isUpdateDisabled || isLoading}
onClick={() => {
handleTransferToTeam(selectedTeamId!).then(() => null);
}}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Transfer
</UpdateAction>
) : (
<UpdateAction
disabled={isUpdateDisabled || isLoading}
onClick={() => {
handleShareChange({
visibility,
sharedTeamMemberIds:
visibility === 'team' ? sharedTeamMemberIds : [],
sharedFriendIds:
visibility === 'friends' ? sharedFriendIds : [],
});
}}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Update Sharing Settings
</UpdateAction>
)}
</div>
</Modal>
);
}
function UpdateAction(props: {
onClick: () => void;
disabled?: boolean;
children: ReactNode;
className?: string;
}) {
const { onClick, disabled, children, className } = props;
return (
<button
className={cn(
'flex min-w-[120px] items-center justify-center gap-1.5 rounded-md border border-gray-900 bg-gray-900 px-4 py-2 text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-75',
disabled && 'border-gray-700 bg-gray-700 text-white hover:bg-gray-700',
className
)}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}

View File

@@ -1,129 +0,0 @@
import {
ArrowLeftRight,
Check,
Globe2,
Lock,
Users,
Users2,
} from 'lucide-react';
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { cn } from '../../lib/classname';
export const allowedVisibilityLabels: {
id: AllowedRoadmapVisibility;
label: string;
long: string;
icon: typeof Lock;
}[] = [
{
id: 'me',
label: 'Only me',
long: 'Only visible to me',
icon: Lock,
},
{
id: 'public',
label: 'Public',
long: 'Anyone can view',
icon: Globe2,
},
{
id: 'friends',
label: 'Only friends',
long: 'Only friends can view',
icon: Users,
},
{
id: 'team',
label: 'Only Members',
long: 'Visible to team members',
icon: Users2,
},
];
type ShareOptionTabsProps = {
visibility: AllowedRoadmapVisibility;
setVisibility: (visibility: AllowedRoadmapVisibility) => void;
teamId?: string;
onChange: (visibility: AllowedRoadmapVisibility) => void;
};
export function ShareOptionTabs(props: ShareOptionTabsProps) {
const { visibility, setVisibility, teamId, onChange } = props;
const handleClick = (visibility: AllowedRoadmapVisibility) => {
setVisibility(visibility);
onChange(visibility);
};
return (
<div className="flex justify-between">
<ul className="flex w-full items-center gap-1.5">
{allowedVisibilityLabels.map((v) => {
if (v.id === 'friends' && teamId) {
return null;
} else if (v.id === 'team' && !teamId) {
return null;
}
const isActive = v.id === visibility;
return (
<li key={v.id}>
<OptionTab
label={v.label}
isActive={isActive}
icon={v.icon}
onClick={() => {
handleClick(v.id);
}}
/>
</li>
);
})}
</ul>
{!teamId && (
<div className="grow">
<OptionTab
label="Transfer to team"
icon={ArrowLeftRight}
isActive={visibility === 'team'}
onClick={() => {
handleClick('team');
}}
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'
/>
</div>
)}
</div>
);
}
type OptionTabProps = {
label: string;
isActive: boolean;
onClick: () => void;
icon: typeof Lock;
className?: string;
};
function OptionTab(props: OptionTabProps) {
const { label, isActive, onClick, icon: Icon, className } = props;
return (
<button
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
)}
data-active={isActive}
disabled={isActive}
onClick={onClick}
>
{!isActive && <Icon className="h-4 w-4" />}
{isActive && <Check className="h-4 w-4" />}
<span className="whitespace-nowrap">{label}</span>
</button>
);
}

View File

@@ -1,164 +0,0 @@
import { useEffect, useState } from 'react';
import { useToast } from '../../hooks/use-toast';
import { UserItem } from './UserItem';
import { Users } from 'lucide-react';
import { httpGet } from '../../lib/http';
const allowedRoles = ['admin', 'manager', 'member'] as const;
const allowedStatus = ['invited', 'joined', 'rejected'] as const;
export type AllowedMemberRoles = (typeof allowedRoles)[number];
export type AllowedMemberStatus = (typeof allowedStatus)[number];
export interface TeamMemberDocument {
_id?: string;
userId?: string;
invitedEmail?: string;
teamId: string;
role: AllowedMemberRoles;
status: AllowedMemberStatus;
progressReminderCount: number;
lastProgressReminderAt?: Date;
lastResendInviteAt?: Date;
resendInviteCount?: number;
createdAt: Date;
updatedAt: Date;
}
export interface TeamMemberList extends TeamMemberDocument {
name: string;
avatar: string;
hasProgress: boolean;
}
type ShareTeamMemberListProps = {
teamId: string;
setMembers: (members: TeamMemberList[]) => void;
members: TeamMemberList[];
sharedTeamMemberIds: string[];
setSharedTeamMemberIds: (sharedTeamMemberIds: string[]) => void;
};
export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
const {
setMembers,
members,
sharedTeamMemberIds,
setSharedTeamMemberIds,
teamId,
} = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
async function loadTeamMembers() {
if (members?.length > 0) {
return;
}
setIsLoading(true);
const { response, error } = await httpGet<TeamMemberList[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-list/${teamId}`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
setMembers(response);
}
useEffect(() => {
loadTeamMembers().finally(() => {
setIsLoading(false);
});
}, []);
const loadingMembers = isLoading && (
<ul className="mt-2 grid grid-cols-3 gap-2.5">
{[...Array(3)].map((_, idx) => (
<li
key={idx}
className="flex min-h-[62px] animate-pulse items-center gap-2 rounded-md border p-2"
>
<div className="h-8 w-8 shrink-0 rounded-full bg-gray-200" />
<div className="inline-grid w-full">
<div className="h-5 w-2/4 rounded bg-gray-200" />
<div className="mt-1 h-5 w-3/4 rounded bg-gray-200" />
</div>
</li>
))}
</ul>
);
return (
<>
{(members.length > 0 || isLoading) && (
<div className="flex items-center justify-between gap-2">
<p className="text-sm">Select Members</p>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={sharedTeamMemberIds.length === members.length}
onChange={(e) => {
if (e.target.checked) {
setSharedTeamMemberIds(members.map((member) => member._id!));
} else {
setSharedTeamMemberIds([]);
}
}}
/>
<span className="text-sm">Select all</span>
</label>
</div>
)}
{loadingMembers}
{members?.length > 0 && !isLoading && (
<ul className="mt-2 grid grid-cols-3 gap-2.5">
{members?.map((member) => {
const isSelected = sharedTeamMemberIds?.includes(
member._id?.toString()!
);
return (
<li key={member.userId}>
<UserItem
user={{
name: member.name,
avatar: member.avatar,
email: member.invitedEmail!,
}}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
setSharedTeamMemberIds(
sharedTeamMemberIds.filter(
(id) => id !== member._id?.toString()!
)
);
} else {
setSharedTeamMemberIds([
...sharedTeamMemberIds,
member._id?.toString()!,
]);
}
}}
/>
</li>
);
})}
</ul>
)}
{members.length === 0 && !isLoading && (
<div className="flex grow flex-col items-center justify-center gap-2">
<Users className="h-12 w-12 text-gray-500" />
<p className="text-gray-500">No members have been added yet.</p>
</div>
)}
</>
);
}

View File

@@ -1,114 +0,0 @@
import { useEffect, useState } from 'react';
import { useToast } from '../../hooks/use-toast';
import { Users2 } from 'lucide-react';
import { httpGet } from '../../lib/http';
import { cn } from '../../lib/classname';
import type { UserTeamItem } from '../TeamDropdown/TeamDropdown';
type TransferToTeamListProps = {
teams: UserTeamItem[];
setTeams: (teams: UserTeamItem[]) => void;
selectedTeamId: string | null;
setSelectedTeamId: (teamId: string | null) => void;
};
export function TransferToTeamList(props: TransferToTeamListProps) {
const { teams, setTeams, selectedTeamId, setSelectedTeamId } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
async function getAllTeams() {
if (teams.length > 0) {
return;
}
const { response, error } = await httpGet<UserTeamItem[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
setTeams(
response.filter((team) => ['admin', 'manager'].includes(team.role))
);
}
useEffect(() => {
getAllTeams().finally(() => setIsLoading(false));
}, []);
const loadingTeams = isLoading && (
<ul className="mt-2 grid grid-cols-3 gap-1.5">
{[...Array(3)].map((_, index) => (
<li key={index}>
<div className="relative flex w-full items-center gap-2 rounded-md border p-2">
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-gray-200" />
<div className="inline-grid w-full">
<div className="h-4 animate-pulse rounded bg-gray-200" />
</div>
</div>
</li>
))}
</ul>
);
return (
<>
{(teams.length > 0 || isLoading) && (
<p className="text-sm">Select a team to transfer this roadmap to</p>
)}
{loadingTeams}
{teams.length > 0 && !isLoading && (
<ul className="mt-2 grid grid-cols-3 gap-1.5">
{teams.map((team) => {
const isSelected = team._id === selectedTeamId;
return (
<li key={team._id}>
<button
className={cn(
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5',
isSelected && 'border-gray-500 bg-gray-100 text-black'
)}
onClick={() => {
setSelectedTeamId(team._id);
}}
>
<img
src={
team.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
team.avatar
}`
: '/images/default-avatar.png'
}
alt={team.name || ''}
className="h-6 w-6 shrink-0 rounded-full"
/>
<div className="inline-grid w-full">
<h3 className="truncate text-left font-normal">
{team.name}
</h3>
</div>
</button>
</li>
);
})}
</ul>
)}
{teams.length === 0 && !isLoading && (
<div className="flex grow flex-col items-center justify-center gap-2">
<Users2 className="h-12 w-12 text-gray-500" />
<p className="text-gray-500">You are not a member of any team.</p>
</div>
)}
</>
);
}

View File

@@ -1,46 +0,0 @@
import { cn } from '../../lib/classname';
type UserItemProps = {
user: {
name: string;
email: string;
avatar: string;
};
onClick: () => void;
isSelected: boolean;
};
export function UserItem(props: UserItemProps) {
const { user, onClick, isSelected } = props;
return (
<button
className={cn(
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5',
isSelected && 'border-gray-500 bg-gray-300 text-black'
)}
onClick={onClick}
>
<img
src={
user.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
: '/images/default-avatar.png'
}
alt={user.name || ''}
className="relative top-[1px] h-10 w-10 shrink-0 rounded-full"
/>
<div className="inline-grid w-full">
<h3 className="truncate text-left font-semibold">{user.name}</h3>
<p
className={cn(
'truncate text-left text-sm text-gray-500',
isSelected && 'text-gray-700'
)}
>
{user.email}
</p>
</div>
</button>
);
}

View File

@@ -1,4 +1,3 @@
import { Fragment } from 'react';
import { CheckIcon } from './ReactIcons/CheckIcon';
type StepperStep = {
@@ -16,14 +15,14 @@ export function Stepper(props: StepperProps) {
const { steps, activeIndex = 0, completeSteps = [] } = props;
return (
<ol className="flex w-full items-center text-gray-500" key="stepper">
<ol className="flex w-full items-center text-gray-500">
{steps.map((step, stepCounter) => {
const isComplete = completeSteps.includes(stepCounter);
const isActive = activeIndex === stepCounter;
const isLast = stepCounter === steps.length - 1;
const isLast = stepCounter === (steps.length - 1);
return (
<Fragment key={stepCounter}>
<>
<li
className={`flex items-center ${
isComplete || isActive ? 'text-black' : 'text-gray-400'
@@ -44,7 +43,7 @@ export function Stepper(props: StepperProps) {
<span className={'h-1 w-full'} />
</li>
)}
</Fragment>
</>
);
})}
</ol>

View File

@@ -112,7 +112,7 @@ export function TeamDropdown() {
)}
</span>
<button
className="relative flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"
className="flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"
onClick={() => setShowDropdown(!showDropdown)}
>
{pendingTeamIds.length > 0 && (

View File

@@ -2,6 +2,8 @@ import { useRef, useState } from 'react';
import type { TeamMemberDocument } from './TeamMembersPage';
import MoreIcon from '../../icons/more-vertical.svg';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useToast } from '../../hooks/use-toast';
import { MailIcon } from '../ReactIcons/MailIcon';
export function MemberActionDropdown({
member,
@@ -31,6 +33,13 @@ export function MemberActionDropdown({
});
const actions = [
{
name: 'Delete',
handleClick: () => {
onDeleteMember();
setIsOpen(false);
},
},
...(allowUpdateRole
? [
{
@@ -64,13 +73,6 @@ export function MemberActionDropdown({
},
]
: []),
{
name: 'Delete',
handleClick: () => {
onDeleteMember();
setIsOpen(false);
},
},
];
return (
<div className="relative">

View File

@@ -109,4 +109,22 @@ export function TeamMemberItem(props: TeamMemberProps) {
</div>
</div>
);
}
}
type SendProgressReminderProps = {
handleSendReminder: () => void;
};
function SendProgressReminder(props: SendProgressReminderProps) {
const { handleSendReminder } = props;
return (
<button
onClick={handleSendReminder}
className="ml-2 flex items-center gap-1.5 whitespace-nowrap rounded-full bg-orange-100 px-2 py-0.5 text-xs text-orange-700"
>
<MailIcon className="h-3 w-3" />
<span>Remind</span>
</button>
);
}

View File

@@ -11,16 +11,12 @@ type GroupRoadmapItemProps = {
export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
const { onShowResourceProgress } = props;
const { members, resourceTitle, resourceId, isCustomResource } =
props.roadmap;
const { members, resourceTitle, resourceId } = props.roadmap;
const { t: teamId } = getUrlParams();
const user = useAuth();
const [showAll, setShowAll] = useState(false);
const roadmapLink = isCustomResource
? `/r?id=${resourceId}`
: `/${resourceId}?t=${teamId}`;
return (
<>
@@ -29,7 +25,7 @@ export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
<div className="flex min-w-0 flex-grow items-center justify-between">
<h3 className="truncate font-medium">{resourceTitle}</h3>
<a
href={roadmapLink}
href={`/${resourceId}?t=${teamId}`}
className="group mb-0.5 flex shrink-0 items-center justify-between text-base font-medium leading-none text-black"
target={'_blank'}
>

View File

@@ -3,10 +3,7 @@ import { useState } from 'react';
type MemberProgressItemProps = {
member: TeamMember;
onShowResourceProgress: (
resourceId: string,
isCustomResource: boolean
) => void;
onShowResourceProgress: (resourceId: string) => void;
isMyProgress?: boolean;
};
export function MemberProgressItem(props: MemberProgressItemProps) {
@@ -32,7 +29,7 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
: '/images/default-avatar.png'
}
alt={member.name || ''}
className="h-8 min-h-[32px] w-8 min-w-[32px] rounded-full"
className="min-w-[32px] min-h-[32px] h-8 w-8 rounded-full"
/>
<div className="inline-grid w-full">
{!isMyProgress && (
@@ -54,12 +51,7 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
(progress) => {
return (
<button
onClick={() =>
onShowResourceProgress(
progress.resourceId,
progress.isCustomResource!
)
}
onClick={() => onShowResourceProgress(progress.resourceId)}
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
key={progress.resourceId}
>

View File

@@ -18,11 +18,6 @@ import { useAuth } from '../../hooks/use-auth';
import { pageProgressMessage } from '../../stores/page';
import { useStore } from '@nanostores/react';
import { $currentTeam } from '../../stores/team';
import { renderFlowJSON } from '../../../renderer/renderer';
import {
allowedClickableNodeTypes,
getNodeDetails,
} from '../CustomRoadmap/RoadmapRenderer';
export type ProgressMapProps = {
member: TeamMember;
@@ -31,7 +26,6 @@ export type ProgressMapProps = {
resourceType: 'roadmap' | 'best-practice';
onClose: () => void;
onShowMyProgress: () => void;
isCustomResource?: boolean;
};
type MemberProgressResponse = {
@@ -49,10 +43,10 @@ export function MemberProgressModal(props: ProgressMapProps) {
onShowMyProgress,
teamId,
onClose,
isCustomResource,
} = props;
const user = useAuth();
const isCurrentUser = user?.email === member.email;
const currentTeam = useStore($currentTeam);
const containerEl = useRef<HTMLDivElement>(null);
const popupBodyEl = useRef<HTMLDivElement>(null);
@@ -70,12 +64,6 @@ export function MemberProgressModal(props: ProgressMapProps) {
resourceJsonUrl += `/best-practices/${resourceId}.json`;
}
if (isCustomResource) {
resourceJsonUrl = `${
import.meta.env.PUBLIC_API_URL
}/v1-get-roadmap/${resourceId}`;
}
async function getMemberProgress(
teamId: string,
memberId: string,
@@ -98,28 +86,11 @@ export function MemberProgressModal(props: ProgressMapProps) {
}
async function renderResource(jsonUrl: string) {
const res = await fetch(jsonUrl, {
...(isCustomResource && {
credentials: 'include',
}),
});
const res = await fetch(jsonUrl);
const json = await res.json();
let svg: SVGElement | null = null;
if (isCustomResource) {
svg = await renderFlowJSON(
{
nodes: json.nodes,
edges: json.edges,
},
{
fontURL: '/fonts/balsamiq.woff2',
}
);
} else {
svg = await wireframeJSONToSVG(json, {
fontURL: '/fonts/balsamiq.woff2',
});
}
const svg = await wireframeJSONToSVG(json, {
fontURL: '/fonts/balsamiq.woff2',
});
containerEl.current?.replaceChildren(svg);
}
@@ -215,28 +186,9 @@ export function MemberProgressModal(props: ProgressMapProps) {
return;
}
let topicId = '';
if (isCustomResource) {
const { nodeId, nodeType } = getNodeDetails(e.target as SVGElement) || {};
if (
!nodeId ||
!nodeType ||
!allowedClickableNodeTypes.includes(nodeType)
) {
return;
}
if (nodeType === 'button') {
return;
}
topicId = nodeId;
} else {
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
}
topicId = groupId.replace(/^\d+-/, '');
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
}
if (targetGroup.classList.contains('removed')) {
@@ -245,9 +197,13 @@ export function MemberProgressModal(props: ProgressMapProps) {
}
e.preventDefault();
const isCurrentStatusDone = targetGroup?.classList.contains('done');
const isCurrentStatusDone = targetGroup.classList.contains('done');
const normalizedGroupId = groupId.replace(/^\d+-/, '');
updateTopicStatus(topicId, !isCurrentStatusDone ? 'done' : 'pending');
updateTopicStatus(
normalizedGroupId,
!isCurrentStatusDone ? 'done' : 'pending'
);
}
async function handleClick(e: MouseEvent) {
@@ -255,28 +211,9 @@ export function MemberProgressModal(props: ProgressMapProps) {
if (!targetGroup) {
return;
}
let topicId = '';
if (isCustomResource) {
const { nodeId, nodeType } = getNodeDetails(e.target as SVGElement) || {};
if (
!nodeId ||
!nodeType ||
!allowedClickableNodeTypes.includes(nodeType)
) {
return;
}
if (nodeType === 'button') {
return;
}
topicId = nodeId;
} else {
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
}
topicId = groupId.replace(/^\d+-/, '');
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
}
if (targetGroup.classList.contains('removed')) {
@@ -284,13 +221,15 @@ export function MemberProgressModal(props: ProgressMapProps) {
}
e.preventDefault();
const normalizedGroupId = groupId.replace(/^\d+-/, '');
const isCurrentStatusLearning = targetGroup.classList.contains('learning');
const isCurrentStatusSkipped = targetGroup.classList.contains('skipped');
if (e.shiftKey) {
e.preventDefault();
updateTopicStatus(
topicId,
normalizedGroupId,
!isCurrentStatusLearning ? 'learning' : 'pending'
);
return;
@@ -299,7 +238,7 @@ export function MemberProgressModal(props: ProgressMapProps) {
if (e.altKey) {
e.preventDefault();
updateTopicStatus(
topicId,
normalizedGroupId,
!isCurrentStatusSkipped ? 'skipped' : 'pending'
);
@@ -340,7 +279,7 @@ export function MemberProgressModal(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
id={isCustomResource ? 'original-roadmap' : 'customized-roadmap'}
id={currentTeam?.type === 'company' ? 'customized-roadmap' : 'original-roadmap'}
className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto"
>
<div
@@ -453,7 +392,7 @@ export function MemberProgressModal(props: ProgressMapProps) {
</div>
<div
id={'resource-svg-wrap'}
id="resource-svg-wrap"
ref={containerEl}
className="px-4 pb-2"
></div>

View File

@@ -20,7 +20,6 @@ export type UserProgress = {
skipped: number;
total: number;
updatedAt: string;
isCustomResource?: boolean;
};
export type TeamMember = {
@@ -37,7 +36,6 @@ export type GroupByRoadmap = {
resourceId: string;
resourceTitle: string;
resourceType: string;
isCustomResource?: boolean;
members: {
member: TeamMember;
progress: UserProgress | undefined;
@@ -60,7 +58,6 @@ export function TeamProgressPage() {
const [showMemberProgress, setShowMemberProgress] = useState<{
resourceId: string;
member: TeamMember;
isCustomResource?: boolean;
}>();
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
@@ -111,7 +108,6 @@ export function TeamProgressPage() {
const groupByRoadmap: GroupByRoadmap[] = [];
for (const roadmap of currentTeam?.roadmaps || []) {
let isCustomResource = false;
const members: GroupByRoadmap['members'] = [];
for (const member of teamMembers) {
const progress = member.progress.find(
@@ -120,10 +116,6 @@ export function TeamProgressPage() {
if (!progress) {
continue;
}
if (progress.isCustomResource && !isCustomResource) {
isCustomResource = true;
}
members.push({
member,
progress,
@@ -139,7 +131,6 @@ export function TeamProgressPage() {
resourceTitle: members?.[0].progress?.resourceTitle || '',
resourceType: 'roadmap',
members,
isCustomResource,
});
}
@@ -160,7 +151,6 @@ export function TeamProgressPage() {
teamId={teamId}
resourceId={showMemberProgress.resourceId}
resourceType={'roadmap'}
isCustomResource={showMemberProgress.isCustomResource}
onClose={() => {
setShowMemberProgress(undefined);
}}
@@ -170,7 +160,6 @@ export function TeamProgressPage() {
member: teamMembers.find(
(member) => member.email === user?.email
)!,
isCustomResource: showMemberProgress.isCustomResource,
});
}}
/>
@@ -204,7 +193,6 @@ export function TeamProgressPage() {
setShowMemberProgress({
resourceId,
member,
isCustomResource: roadmap.isCustomResource,
});
}}
/>
@@ -219,11 +207,10 @@ export function TeamProgressPage() {
key={member._id}
member={member}
isMyProgress={member?.email === user?.email}
onShowResourceProgress={(resourceId, isCustomResource) => {
onShowResourceProgress={(resourceId) => {
setShowMemberProgress({
resourceId,
member,
isCustomResource,
});
}}
/>

View File

@@ -1,3 +0,0 @@
export function CustomTeamRoadmap() {
return null;
}

View File

@@ -1,3 +0,0 @@
export function DefaultTeamRoadmap() {
return null;
}

View File

@@ -0,0 +1,346 @@
import { getUrlParams } from '../lib/browser';
import { useEffect, useState } from 'react';
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
import { httpGet, httpPut } from '../lib/http';
import { pageProgressMessage } from '../stores/page';
import ExternalLinkIcon from '../icons/external-link.svg';
import RoadmapIcon from '../icons/roadmap.svg';
import PlusIcon from '../icons/plus.svg';
import type { PageType } from './CommandMenu/CommandMenu';
import { UpdateTeamResourceModal } from './CreateTeam/UpdateTeamResourceModal';
import { useStore } from '@nanostores/react';
import { $canManageCurrentTeam } from '../stores/team';
import { useToast } from '../hooks/use-toast';
import { SelectRoadmapModal } from './CreateTeam/SelectRoadmapModal';
export function TeamRoadmaps() {
const { t: teamId } = getUrlParams();
const canManageCurrentTeam = useStore($canManageCurrentTeam);
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false);
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
const [team, setTeam] = useState<TeamDocument>();
const [resourceConfigs, setResourceConfigs] = useState<TeamResourceConfig>(
[]
);
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
async function loadAllRoadmaps() {
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (error) {
toast.error(error.message || 'Something went wrong');
return;
}
if (!response) {
return [];
}
const allRoadmaps = response
.filter((page) => page.group === 'Roadmaps')
.sort((a, b) => {
if (a.title === 'Android') return 1;
return a.title.localeCompare(b.title);
});
setAllRoadmaps(allRoadmaps);
return response;
}
async function loadTeam(teamIdToFetch: string) {
const { response, error } = await httpGet<TeamDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
);
if (error || !response) {
toast.error('Error loading team');
window.location.href = '/account';
return;
}
setTeam(response);
}
async function loadTeamResourceConfig(teamId: string) {
const { error, response } = await httpGet<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
);
if (error || !Array.isArray(response)) {
console.error(error);
return;
}
setResourceConfigs(response);
}
useEffect(() => {
if (!teamId) {
return;
}
setIsLoading(true);
Promise.all([
loadTeam(teamId),
loadTeamResourceConfig(teamId),
loadAllRoadmaps(),
]).finally(() => {
pageProgressMessage.set('');
setIsLoading(false);
});
}, [teamId]);
async function deleteResource(roadmapId: string) {
if (!team?._id) {
return;
}
toast.loading('Deleting roadmap');
pageProgressMessage.set(`Deleting roadmap from team`);
const { error, response } = await httpPut<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
team._id
}`,
{
resourceId: roadmapId,
resourceType: 'roadmap',
}
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success('Roadmap removed');
setResourceConfigs(response);
}
async function onAdd(roadmapId: string) {
if (!teamId) {
return;
}
toast.loading('Adding roadmap');
pageProgressMessage.set('Adding roadmap');
setIsLoading(true);
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-update-team-resource-config/${teamId}`,
{
teamId: teamId,
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
}
);
if (error || !response) {
toast.error(error?.message || 'Error adding roadmap');
return;
}
setResourceConfigs(response);
toast.success('Roadmap added');
}
async function onRemove(resourceId: string) {
pageProgressMessage.set('Removing roadmap');
deleteResource(resourceId).finally(() => {
pageProgressMessage.set('');
});
}
if (!team) {
return null;
}
const addRoadmapModal = isAddingRoadmap && (
<SelectRoadmapModal
onClose={() => setIsAddingRoadmap(false)}
teamResourceConfig={resourceConfigs}
allRoadmaps={allRoadmaps}
teamId={teamId}
onRoadmapAdd={(roadmapId) => {
onAdd(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}}
onRoadmapRemove={(roadmapId) => {
if (confirm('Are you sure you want to remove this roadmap?')) {
onRemove(roadmapId).finally(() => {});
}
}}
/>
);
if (resourceConfigs.length === 0 && !isLoading) {
return (
<div className="flex flex-col items-center p-4 py-20">
{addRoadmapModal}
<img
alt="roadmap"
src={RoadmapIcon.src}
className="mb-4 h-24 w-24 opacity-10"
/>
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
<p className="text-base text-gray-500">
{canManageCurrentTeam
? 'Add a roadmap to start tracking your team'
: 'Ask your team admin to add some roadmaps'}
</p>
{canManageCurrentTeam && (
<button
className="mt-4 rounded-lg bg-black px-4 py-2 font-medium text-white hover:bg-gray-900"
onClick={() => setIsAddingRoadmap(true)}
>
Add roadmap
</button>
)}
</div>
);
}
return (
<div>
{addRoadmapModal}
<div className="mb-3 flex items-center justify-between">
<span className={'text-gray-400'}>
{resourceConfigs.length} roadmap(s) selected
</span>
{canManageCurrentTeam && (
<button
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-gray-500 underline hover:bg-gray-100 hover:text-gray-900"
onClick={() => setIsAddingRoadmap(true)}
>
Add / Remove Roadmaps
</button>
)}
</div>
<div className={'grid grid-cols-1 gap-3 sm:grid-cols-2'}>
{changingRoadmapId && (
<UpdateTeamResourceModal
onClose={() => setChangingRoadmapId('')}
resourceId={changingRoadmapId}
resourceType={'roadmap'}
teamId={team?._id!}
setTeamResourceConfig={setResourceConfigs}
defaultRemovedItems={
resourceConfigs.find((c) => c.resourceId === changingRoadmapId)
?.removed || []
}
/>
)}
{resourceConfigs.map((resourceConfig) => {
const { resourceId, removed: removedTopics } = resourceConfig;
const roadmapTitle =
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
'...';
return (
<div key={resourceId} className="flex flex-col items-start rounded-md border border-gray-300">
<div className={'w-full px-3 py-4'}>
<a
href={`/${resourceId}?t=${teamId}`}
className="group mb-0.5 flex items-center justify-between text-base font-medium leading-none text-black"
target={'_blank'}
>
{roadmapTitle}
<img
alt={'link'}
src={ExternalLinkIcon.src}
className="ml-2 h-4 w-4 opacity-20 transition-opacity group-hover:opacity-100"
/>
</a>
{removedTopics.length > 0 ? (
<span className={'text-xs leading-none text-gray-900'}>
{removedTopics.length} topic
{removedTopics.length > 1 ? 's' : ''} removed
</span>
) : (
<span className="text-xs italic leading-none text-gray-400/60">
No changes made ..
</span>
)}
</div>
{canManageCurrentTeam && (
<div className={'flex w-full justify-between px-3 pb-3 pt-2'}>
<button
type="button"
className={
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
}
onClick={() => {
setRemovingRoadmapId('');
setChangingRoadmapId(resourceId);
}}
>
Customize
</button>
{removingRoadmapId !== resourceId && (
<button
type="button"
className={
'text-xs text-red-500 underline hover:text-black focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-red-500'
}
onClick={() => setRemovingRoadmapId(resourceId)}
>
Remove
</button>
)}
{removingRoadmapId === resourceId && (
<span className="text-xs">
Are you sure?{' '}
<button
onClick={() => onRemove(resourceId)}
className="mx-0.5 text-red-500 underline underline-offset-1"
>
Yes
</button>{' '}
<button
onClick={() => setRemovingRoadmapId('')}
className="text-red-500 underline underline-offset-1"
>
No
</button>
</span>
)}
</div>
)}
</div>
);
})}
{canManageCurrentTeam && (
<button
onClick={() => setIsAddingRoadmap(true)}
className="group flex min-h-[110px] flex-col items-center justify-center rounded-md border border-dashed border-gray-300 transition-colors hover:border-gray-600 hover:bg-gray-50"
>
<img
alt="add"
src={PlusIcon.src}
className="mb-1 h-6 w-6 opacity-20 transition-opacity group-hover:opacity-100"
/>
<span className="text-sm text-gray-400 transition-colors focus:outline-none group-hover:text-black">
Add Roadmap
</span>
</button>
)}
</div>
</div>
);
}

View File

@@ -1,39 +0,0 @@
import { Modal } from '../Modal';
import { Map, Shapes } from 'lucide-react';
type PickRoadmapOptionModalProps = {
onClose: () => void;
showDefaultRoadmapsModal: () => void;
showCreateCustomRoadmapModal: () => void;
};
export function PickRoadmapOptionModal(props: PickRoadmapOptionModalProps) {
const { onClose, showDefaultRoadmapsModal, showCreateCustomRoadmapModal } =
props;
return (
<Modal onClose={onClose} bodyClassName="p-4">
<h2 className="mb-0.5 text-left text-2xl font-semibold">Pick an Option</h2>
<p className="text-left text-sm text-gray-500 mb-4">
Choose from default roadmaps or create from scratch.
</p>
<div className="flex flex-col gap-2">
<button
className="text-base flex items-center rounded-md border border-gray-300 p-2 px-4 text-left font-medium hover:bg-gray-100"
onClick={showDefaultRoadmapsModal}
>
<Map className="mr-2 inline-block" size={20} />
Use a Default Roadmap
</button>
<button
className="text-base flex items-center rounded-md border border-gray-300 p-2 px-4 text-left font-medium hover:bg-gray-100"
onClick={showCreateCustomRoadmapModal}
>
<Shapes className="mr-2 inline-block" size={20} />
Create from Scratch
</button>
</div>
</Modal>
);
}

View File

@@ -1,93 +0,0 @@
import MoreIcon from '../../icons/more-vertical.svg';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
type RoadmapActionDropdownProps = {
onDelete?: () => void;
onCustomize?: () => void;
onUpdateSharing?: () => void;
};
export function RoadmapActionDropdown(props: RoadmapActionDropdownProps) {
const { onDelete, onUpdateSharing, onCustomize } = props;
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
useOutsideClick(menuRef, () => {
setIsOpen(false);
});
return (
<div className="relative">
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex"
>
<img alt="menu" src={MoreIcon.src} className="h-4 w-4" />
</button>
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none sm:hidden"
>
<MoreVertical size={14} />
Options
</button>
{isOpen && (
<div
ref={menuRef}
className="align-right absolute right-auto top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md sm:right-0"
>
<ul>
{onUpdateSharing && (
<li>
<button
onClick={() => {
setIsOpen(false);
onUpdateSharing();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Lock size={14} className="mr-2" />
Sharing
</button>
</li>
)}
{onCustomize && (
<li>
<button
onClick={() => {
setIsOpen(false);
onCustomize();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Shapes size={14} className="mr-2" />
Customize
</button>
</li>
)}
{onDelete && (
<li>
<button
onClick={() => {
setIsOpen(false);
onDelete();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Trash2 size={14} className="mr-2" />
Delete
</button>
</li>
)}
</ul>
</div>
)}
</div>
);
}

View File

@@ -1,636 +0,0 @@
import { getUrlParams } from '../../lib/browser';
import { useEffect, useState } from 'react';
import type { TeamDocument } from '../CreateTeam/CreateTeamForm';
import type { TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
import { httpGet, httpPut } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import RoadmapIcon from '../../icons/roadmap.svg';
import type { PageType } from '../CommandMenu/CommandMenu';
import { useStore } from '@nanostores/react';
import { $canManageCurrentTeam } from '../../stores/team';
import { useToast } from '../../hooks/use-toast';
import { SelectRoadmapModal } from '../CreateTeam/SelectRoadmapModal';
import { PickRoadmapOptionModal } from '../TeamRoadmaps/PickRoadmapOptionModal';
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import {
ExternalLink,
Globe,
LockIcon,
type LucideIcon,
Package,
PackageMinus,
PenSquare,
Shapes,
Users,
} from 'lucide-react';
import { RoadmapActionDropdown } from './RoadmapActionDropdown';
import { UpdateTeamResourceModal } from '../CreateTeam/UpdateTeamResourceModal';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
export function TeamRoadmaps() {
const { t: teamId } = getUrlParams();
const canManageCurrentTeam = useStore($canManageCurrentTeam);
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isPickingOptions, setIsPickingOptions] = useState(false);
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false);
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
const [team, setTeam] = useState<TeamDocument>();
const [teamResources, setTeamResources] = useState<TeamResourceConfig>([]);
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
const [selectedResource, setSelectedResource] = useState<
TeamResourceConfig[0] | null
>(null);
async function loadAllRoadmaps() {
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (error) {
toast.error(error.message || 'Something went wrong');
return;
}
if (!response) {
return [];
}
const allRoadmaps = response
.filter((page) => page.group === 'Roadmaps')
.sort((a, b) => {
if (a.title === 'Android') return 1;
return a.title.localeCompare(b.title);
});
setAllRoadmaps(allRoadmaps);
return response;
}
async function loadTeam(teamIdToFetch: string) {
const { response, error } = await httpGet<TeamDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
);
if (error || !response) {
toast.error('Error loading team');
window.location.href = '/account';
return;
}
setTeam(response);
}
async function loadTeamResourceConfig(teamId: string) {
const { error, response } = await httpGet<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
);
if (error || !Array.isArray(response)) {
console.error(error);
return;
}
setTeamResources(response);
}
useEffect(() => {
if (!teamId) {
return;
}
setIsLoading(true);
Promise.all([
loadTeam(teamId),
loadTeamResourceConfig(teamId),
loadAllRoadmaps(),
]).finally(() => {
pageProgressMessage.set('');
setIsLoading(false);
});
}, [teamId]);
async function deleteResource(roadmapId: string) {
if (!team?._id) {
return;
}
toast.loading('Deleting roadmap');
pageProgressMessage.set(`Deleting roadmap from team`);
const { error, response } = await httpPut<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
team._id
}`,
{
resourceId: roadmapId,
resourceType: 'roadmap',
}
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success('Roadmap removed');
setTeamResources(response);
}
async function onAdd(roadmapId: string) {
if (!teamId) {
return;
}
toast.loading('Adding roadmap');
pageProgressMessage.set('Adding roadmap');
setIsLoading(true);
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-update-team-resource-config/${teamId}`,
{
teamId: teamId,
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
}
);
if (error || !response) {
toast.error(error?.message || 'Error adding roadmap');
return;
}
setTeamResources(response);
toast.success('Roadmap added');
}
async function onRemove(resourceId: string) {
pageProgressMessage.set('Removing roadmap');
deleteResource(resourceId).finally(() => {
pageProgressMessage.set('');
});
}
useEffect(() => {
function handleCustomRoadmapCreated(event: Event) {
const { roadmapId } = (event as CustomEvent)?.detail;
if (!roadmapId) {
return;
}
loadAllRoadmaps().finally(() => {});
onAdd(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}
window.addEventListener(
'custom-roadmap-created',
handleCustomRoadmapCreated
);
return () => {
window.removeEventListener(
'custom-roadmap-created',
handleCustomRoadmapCreated
);
};
}, []);
if (!team) {
return null;
}
const pickRoadmapOptionModal = isPickingOptions && (
<PickRoadmapOptionModal
onClose={() => setIsPickingOptions(false)}
showDefaultRoadmapsModal={() => {
setIsAddingRoadmap(true);
setIsPickingOptions(false);
}}
showCreateCustomRoadmapModal={() => {
setIsCreatingRoadmap(true);
setIsPickingOptions(false);
}}
/>
);
const addRoadmapModal = isAddingRoadmap && (
<SelectRoadmapModal
onClose={() => setIsAddingRoadmap(false)}
teamResourceConfig={teamResources}
allRoadmaps={allRoadmaps}
teamId={teamId}
onRoadmapAdd={(roadmapId: string) => {
onAdd(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}}
onRoadmapRemove={(roadmapId: string) => {
if (confirm('Are you sure you want to remove this roadmap?')) {
onRemove(roadmapId).finally(() => {});
}
}}
/>
);
const createRoadmapModal = isCreatingRoadmap && (
<CreateRoadmapModal
teamId={teamId}
onClose={() => {
setIsCreatingRoadmap(false);
}}
onCreated={() => {
loadTeamResourceConfig(teamId).finally(() => null);
setIsCreatingRoadmap(false);
}}
/>
);
const placeholderRoadmaps = teamResources.filter(
(c: TeamResourceConfig[0]) => c.isCustomResource && !c.topics
);
const customRoadmaps = teamResources.filter(
(c: TeamResourceConfig[0]) => c.isCustomResource && c.topics
);
const defaultRoadmaps = teamResources.filter(
(c: TeamResourceConfig[0]) => !c.isCustomResource
);
const hasRoadmaps =
customRoadmaps.length > 0 ||
defaultRoadmaps.length > 0 ||
(placeholderRoadmaps.length > 0 && canManageCurrentTeam);
if (!hasRoadmaps && !isLoading) {
return (
<div className="flex flex-col items-center p-4 py-20">
{pickRoadmapOptionModal}
{addRoadmapModal}
{createRoadmapModal}
<img
alt="roadmap"
src={RoadmapIcon.src}
className="mb-4 h-24 w-24 opacity-10"
/>
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
<p className="text-base text-gray-500">
{canManageCurrentTeam
? 'Add a roadmap to start tracking your team'
: 'Ask your team admin to add some roadmaps'}
</p>
{canManageCurrentTeam && (
<button
className="mt-4 rounded-lg bg-black px-4 py-2 font-medium text-white hover:bg-gray-900"
onClick={() => setIsPickingOptions(true)}
>
Add roadmap
</button>
)}
</div>
);
}
const customizeRoadmapModal = changingRoadmapId && (
<UpdateTeamResourceModal
onClose={() => setChangingRoadmapId('')}
resourceId={changingRoadmapId}
resourceType={'roadmap'}
teamId={team?._id!}
setTeamResourceConfig={setTeamResources}
defaultRemovedItems={
defaultRoadmaps.find((c) => c.resourceId === changingRoadmapId)
?.removed || []
}
/>
);
const shareSettingsModal = selectedResource && (
<ShareOptionsModal
visibility={selectedResource.visibility!}
sharedTeamMemberIds={selectedResource.sharedTeamMemberIds!}
sharedFriendIds={selectedResource.sharedFriendIds!}
teamId={teamId}
roadmapId={selectedResource.resourceId}
onShareSettingsUpdate={(shareSettings) => {
setTeamResources((prev) => {
return prev.map((c) => {
if (c.resourceId !== selectedResource.resourceId) {
return c;
}
return {
...c,
...shareSettings,
};
});
});
}}
onClose={() => setSelectedResource(null)}
/>
);
return (
<div>
{pickRoadmapOptionModal}
{addRoadmapModal}
{createRoadmapModal}
{customizeRoadmapModal}
{shareSettingsModal}
{canManageCurrentTeam && placeholderRoadmaps.length > 0 && (
<div className="mb-5">
<div className="mb-2 flex items-center justify-between">
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400">
<span className="flex">Placeholder Roadmaps</span>
<span className="normal-case">
Total {placeholderRoadmaps.length} roadmap(s)
</span>
</h3>
</div>
<div className="flex flex-col divide-y rounded-md border">
{placeholderRoadmaps.map(
(resourceConfig: TeamResourceConfig[0]) => {
return (
<div
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_173px]"
key={resourceConfig.resourceId}
>
<div className="mb-3 grid sm:mb-0">
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
{resourceConfig.title}
</p>
<span className="text-xs italic leading-none text-gray-400/60">
Placeholder roadmap
</span>
</div>
{canManageCurrentTeam && (
<div className="flex items-center justify-start gap-2 sm:justify-end">
<RoadmapActionDropdown
onUpdateSharing={() => {
setSelectedResource(resourceConfig);
}}
onDelete={() => {
if (
confirm(
'Are you sure you want to remove this roadmap?'
)
) {
onRemove(resourceConfig.resourceId).finally(
() => {}
);
}
}}
/>
<a
href={`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
resourceConfig.resourceId
}`}
className={
'flex gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
}
target={'_blank'}
>
<PenSquare className="inline-block h-4 w-4" />
Create Roadmap
</a>
</div>
)}
</div>
);
}
)}
</div>
</div>
)}
{customRoadmaps.length > 0 && (
<div className="mb-5">
<div className="mb-2 flex items-center justify-between">
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400">
<span className="flex">Custom Roadmaps</span>
<span className="normal-case">
Total {customRoadmaps.length} roadmap(s)
</span>
</h3>
</div>
<div className="flex flex-col divide-y rounded-md border">
{customRoadmaps.map((resourceConfig: TeamResourceConfig[0]) => {
const editorLink = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
resourceConfig.resourceId
}`;
return (
<div
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_110px]"
key={resourceConfig.resourceId}
>
<div className="mb-3 grid grid-cols-1 sm:mb-0">
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
{resourceConfig.title}
</p>
<span className="flex items-center text-xs leading-none text-gray-400">
<VisibilityBadge
visibility={resourceConfig.visibility!}
sharedTeamMemberIds={resourceConfig.sharedTeamMemberIds}
sharedFriendIds={resourceConfig.sharedFriendIds}
/>
<span className="mx-2 font-semibold">&middot;</span>
<Shapes size={16} className="mr-1 inline-block h-4 w-4" />
{resourceConfig.topics} topic
</span>
</div>
<div className="mr-1 flex items-center justify-start sm:justify-end">
{canManageCurrentTeam && (
<RoadmapActionDropdown
onUpdateSharing={() => {
setSelectedResource(resourceConfig);
}}
onCustomize={() => {
window.open(editorLink, '_blank');
}}
onDelete={() => {
if (
confirm(
'Are you sure you want to remove this roadmap?'
)
) {
onRemove(resourceConfig.resourceId).finally(
() => {}
);
}
}}
/>
)}
<a
href={`/r?id=${resourceConfig.resourceId}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
}
target={'_blank'}
>
<ExternalLink className="inline-block h-4 w-4" />
Visit
</a>
</div>
</div>
);
})}
</div>
</div>
)}
{defaultRoadmaps.length > 0 && (
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400">
<span className="flex">Default Roadmaps</span>
<span className="normal-case">
Total {defaultRoadmaps.length} roadmap(s)
</span>
</h3>
</div>
<div className="flex flex-col divide-y rounded-md border">
{defaultRoadmaps.map((resourceConfig: TeamResourceConfig[0]) => {
return (
<div
className="grid grid-cols-1 p-3 sm:grid-cols-[auto_110px]"
key={resourceConfig.resourceId}
>
<div className="mb-3 grid grid-cols-1 sm:mb-0">
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
{resourceConfig.title}
</p>
<span className="flex items-center text-xs leading-none text-gray-400">
{resourceConfig?.removed?.length > 0 && (
<>
<PackageMinus
size={16}
className="mr-1 inline-block h-4 w-4"
/>
{resourceConfig.removed.length} topics removed
</>
)}
{!resourceConfig?.removed?.length && (
<>
<Package
size={16}
className="mr-1 inline-block h-4 w-4"
/>
No changes made
</>
)}
</span>
</div>
<div className="mr-1 flex items-center justify-start sm:justify-end">
{canManageCurrentTeam && (
<RoadmapActionDropdown
onCustomize={() => {
setChangingRoadmapId(resourceConfig.resourceId);
}}
onDelete={() => {
if (
confirm(
'Are you sure you want to remove this roadmap?'
)
) {
onRemove(resourceConfig.resourceId).finally(
() => {}
);
}
}}
/>
)}
<a
href={`/${resourceConfig.resourceId}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
}
target={'_blank'}
>
<ExternalLink className="inline-block h-4 w-4" />
Visit
</a>
</div>
</div>
);
})}
</div>
</div>
)}
{canManageCurrentTeam && (
<div className="mt-5">
<button
className="block w-full rounded-md border border-dashed border-gray-300 py-2 text-sm transition-colors hover:border-gray-600 hover:bg-gray-50 focus:outline-0"
onClick={() => setIsPickingOptions(true)}
>
+ Add new Roadmap
</button>
</div>
)}
</div>
);
}
type VisibilityLabelProps = {
visibility: AllowedRoadmapVisibility;
sharedTeamMemberIds?: string[];
sharedFriendIds?: string[];
};
const visibilityDetails: Record<
AllowedRoadmapVisibility,
{
icon: LucideIcon;
label: string;
}
> = {
public: {
icon: Globe,
label: 'Public',
},
me: {
icon: LockIcon,
label: 'Only me',
},
team: {
icon: Users,
label: 'Team Member(s)',
},
friends: {
icon: Users,
label: 'Friend(s)',
},
} as const;
export function VisibilityBadge(props: VisibilityLabelProps) {
const { visibility, sharedTeamMemberIds = [], sharedFriendIds = [] } = props;
const { label, icon: Icon } = visibilityDetails[visibility];
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`}
>
<Icon className="inline-block h-3 w-3" />
<div className="flex items-center">
{visibility === 'team' && sharedTeamMemberIds?.length > 0 && (
<span className="mr-1">{sharedTeamMemberIds.length}</span>
)}
{visibility === 'friends' && sharedFriendIds?.length > 0 && (
<span className="mr-1">{sharedFriendIds.length}</span>
)}
{label}
</div>
</span>
);
}

View File

@@ -1,16 +1,21 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import ChevronDown from '../icons/dropdown.svg';
import { httpGet } from '../lib/http';
import { useTeamId } from '../hooks/use-team-id';
import { useAuth } from '../hooks/use-auth';
import { useOutsideClick } from '../hooks/use-outside-click';
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
import { pageProgressMessage } from '../stores/page';
import { useToast } from '../hooks/use-toast';
import { type UserTeamItem } from './TeamDropdown/TeamDropdown';
type TeamListResponse = TeamDocument[];
export function TeamsList() {
const [teamList, setTeamList] = useState<UserTeamItem[]>([]);
const [teamList, setTeamList] = useState<TeamDocument[]>([]);
const user = useAuth();
const toast = useToast();
async function getAllTeam() {
const { response, error } = await httpGet<UserTeamItem[]>(
const { response, error } = await httpGet<TeamListResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
);
if (error || !response) {
@@ -59,39 +64,30 @@ export function TeamsList() {
<span>&rarr;</span>
</a>
</li>
{teamList.map((team) => {
let pageLink = '';
if (team.status === 'invited') {
pageLink = `/respond-invite?i=${team.memberId}`;
} else if (team.status === 'joined') {
pageLink = `/team/progress?t=${team._id}`;
}
return (
<li key={team._id}>
<a
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50"
href={pageLink}
>
<span className="flex flex-grow items-center gap-2">
<img
src={
team.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
team.avatar
}`
: '/images/default-avatar.png'
}
alt={team.name || ''}
className="h-6 w-6 rounded-full"
/>
<span className="truncate">{team.name}</span>
</span>
<span>&rarr;</span>
</a>
</li>
);
})}
{teamList.map((team) => (
<li key={team._id}>
<a
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50"
href={`/team/progress?t=${team._id}`}
>
<span className="flex flex-grow items-center gap-2">
<img
src={
team.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
team.avatar
}`
: '/images/default-avatar.png'
}
alt={team.name || ''}
className="h-6 w-6 rounded-full"
/>
<span className="truncate">{team.name}</span>
</span>
<span>&rarr;</span>
</a>
</li>
))}
</ul>
<a
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"

View File

@@ -53,7 +53,7 @@ export function Toaster(props: Props) {
onClick={() => {
$toastMessage.set(undefined);
}}
className={`fixed bottom-5 left-1/2 z-[9999] min-w-[375px] max-w-[375px] animate-fade-slide-up sm:min-w-[auto]`}
className={`fixed bottom-5 left-1/2 z-50 min-w-[375px] max-w-[375px] animate-fade-slide-up sm:min-w-[auto]`}
>
<div
className={`flex -translate-x-1/2 transform cursor-pointer items-center gap-2 rounded-md border border-gray-200 bg-white py-3 pl-4 pr-5 text-black shadow-md hover:bg-gray-50`}

View File

@@ -1,61 +0,0 @@
import { type ReactNode } from 'react';
import { clsx } from 'clsx';
type TooltipProps = {
children: ReactNode;
position?:
| 'right-center'
| 'right-top'
| 'right-bottom'
| 'left-center'
| 'left-top'
| 'left-bottom'
| 'top-center'
| 'top-left'
| 'top-right'
| 'bottom-center'
| 'bottom-left'
| 'bottom-right';
};
export function Tooltip(props: TooltipProps) {
const { children, position = 'right-center' } = props;
let positionClass = '';
if (position === 'right-center') {
positionClass = 'left-full top-1/2 -translate-y-1/2 translate-x-1 ';
} else if (position === 'top-center') {
positionClass = 'bottom-full left-1/2 -translate-x-1/2 -translate-y-0.5';
} else if (position === 'bottom-center') {
positionClass = 'top-full left-1/2 -translate-x-1/2 translate-y-0.5';
} else if (position === 'left-center') {
positionClass = 'right-full top-1/2 -translate-y-1/2 -translate-x-1';
} else if (position === 'right-top') {
positionClass = 'left-full top-0';
} else if (position === 'right-bottom') {
positionClass = 'left-full bottom-0';
} else if (position === 'left-top') {
positionClass = 'right-full top-0';
} else if (position === 'left-bottom') {
positionClass = 'right-full bottom-0';
} else if (position === 'top-left') {
positionClass = 'bottom-full left-0';
} else if (position === 'top-right') {
positionClass = 'bottom-full right-0';
} else if (position === 'bottom-left') {
positionClass = 'top-full left-0';
} else if (position === 'bottom-right') {
positionClass = 'top-full right-0';
}
return (
<span
className={clsx(
'pointer-events-none absolute z-10 block w-max transform rounded-md bg-gray-900 px-2 py-1 text-sm font-medium text-white opacity-0 shadow-sm duration-100 group-hover:opacity-100',
positionClass
)}
>
{children}
</span>
);
}

View File

@@ -20,42 +20,16 @@ import { TopicProgressButton } from './TopicProgressButton';
import { ContributionForm } from './ContributionForm';
import { showLoginPopup } from '../../lib/popup';
import { useToast } from '../../hooks/use-toast';
import type {
AllowedLinkTypes,
RoadmapContentDocument,
} from '../CustomRoadmap/CustomRoadmap';
import { markdownToHtml } from '../../lib/markdown';
import { cn } from '../../lib/classname';
import { Ban, FileText } from 'lucide-react';
import { getUrlParams } from '../../lib/browser';
type TopicDetailProps = {
canSubmitContribution: boolean;
};
const linkTypes: Record<AllowedLinkTypes, string> = {
article: 'bg-yellow-200',
course: 'bg-green-200',
opensource: 'bg-blue-200',
podcast: 'bg-purple-200',
video: 'bg-pink-200',
website: 'bg-red-200',
};
export function TopicDetail(props: TopicDetailProps) {
const { canSubmitContribution } = props;
export function TopicDetail() {
const [contributionAlertMessage, setContributionAlertMessage] = useState('');
const [isActive, setIsActive] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isContributing, setIsContributing] = useState(false);
const [error, setError] = useState('');
const [topicHtml, setTopicHtml] = useState('');
const [topicTitle, setTopicTitle] = useState('');
const [links, setLinks] = useState<RoadmapContentDocument['links']>([]);
const toast = useToast();
const { secret } = getUrlParams() as { secret: string };
const isGuest = useMemo(() => !isLoggedIn(), []);
const topicRef = useRef<HTMLDivElement>(null);
@@ -115,8 +89,7 @@ export function TopicDetail(props: TopicDetailProps) {
});
// Load the topic detail when the topic detail is active
useLoadTopic(({ topicId, resourceType, resourceId, isCustomResource }) => {
setError('');
useLoadTopic(({ topicId, resourceType, resourceId }) => {
setIsLoading(true);
setIsActive(true);
sponsorHidden.set(true);
@@ -127,53 +100,30 @@ export function TopicDetail(props: TopicDetailProps) {
setResourceId(resourceId);
const topicPartial = topicId.replaceAll(':', '/');
let topicUrl =
const topicUrl =
resourceType === 'roadmap'
? `/${resourceId}/${topicPartial}`
: `/best-practices/${resourceId}/${topicPartial}`;
if (isCustomResource) {
topicUrl = `${
import.meta.env.PUBLIC_API_URL
}/v1-get-node-content/${resourceId}/${topicId}${
secret ? `?secret=${secret}` : ''
}`;
}
httpGet<string | RoadmapContentDocument>(
httpGet<string>(
topicUrl,
{},
{
...(!isCustomResource && {
headers: {
Accept: 'text/html',
},
}),
headers: {
Accept: 'text/html',
},
}
)
.then(({ response }) => {
if (!response) {
setError('Topic not found.');
setIsLoading(false);
return;
}
let topicHtml = '';
if (!isCustomResource) {
// It's full HTML with page body, head etc.
// We only need the inner HTML of the #main-content
const node = new DOMParser().parseFromString(
response as string,
'text/html'
);
topicHtml = node?.getElementById('main-content')?.outerHTML || '';
} else {
setLinks((response as RoadmapContentDocument)?.links || []);
setTopicTitle((response as RoadmapContentDocument)?.title || '');
topicHtml = markdownToHtml(
(response as RoadmapContentDocument)?.description || '',
false
);
}
// It's full HTML with page body, head etc.
// We only need the inner HTML of the #main-content
const node = new DOMParser().parseFromString(response, 'text/html');
const topicHtml = node?.getElementById('main-content')?.outerHTML || '';
setIsLoading(false);
setTopicHtml(topicHtml);
@@ -188,10 +138,8 @@ export function TopicDetail(props: TopicDetailProps) {
return null;
}
const hasContent = topicHtml?.length > 0 || links?.length > 0 || topicTitle;
return (
<div className={'relative z-50'}>
<div>
<div
ref={topicRef}
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6"
@@ -249,96 +197,35 @@ export function TopicDetail(props: TopicDetailProps) {
</div>
{/* Topic Content */}
{hasContent ? (
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 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>
)}
{links.length > 0 && (
<ul className="mt-6 space-y-1">
{links.map((link) => {
return (
<li>
<a
href={link.url}
target="_blank"
className="font-medium underline"
>
<span
className={cn(
'mr-2 inline-block rounded px-1.5 py-1 text-xs uppercase no-underline',
linkTypes[link.type]
)}
>
{link.type.charAt(0).toUpperCase() +
link.type.slice(1)}
</span>
{link.title}
</a>
</li>
);
})}
</ul>
)}
<div
id="topic-content"
className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 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"
dangerouslySetInnerHTML={{ __html: topicHtml }}
></div>
{/* Contribution */}
{canSubmitContribution && (
<div className="mt-8 flex-1 border-t">
<p className="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
Help others learn by submitting links to learn more about this
topic{' '}
</p>
<button
onClick={() => {
if (isGuest) {
setIsActive(false);
showLoginPopup();
return;
}
<div className="mt-8 flex-1 border-t">
<p className="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
Help others learn by submitting links to learn more about this
topic{' '}
</p>
<button
onClick={() => {
if (isGuest) {
setIsActive(false);
showLoginPopup();
return;
}
setIsContributing(true);
}}
disabled={!!contributionAlertMessage}
className="block w-full 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"
>
{contributionAlertMessage
? contributionAlertMessage
: 'Submit a Link'}
</button>
</div>
)}
</>
)}
{/* Error */}
{!isContributing && !isLoading && error && (
<>
<button
type="button"
id="close-topic"
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={() => {
setIsActive(false);
setIsContributing(false);
}}
>
<img alt="Close" className="h-5 w-5" src={CloseIcon.src} />
</button>
<div className="flex h-full flex-col items-center justify-center">
<Ban className="h-16 w-16 text-red-500" />
<p className="mt-2 text-lg font-medium text-red-500">{error}</p>
setIsContributing(true);
}}
disabled={!!contributionAlertMessage}
className="block w-full 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"
>
{contributionAlertMessage
? contributionAlertMessage
: 'Submit a Link'}
</button>
</div>
</>
)}

View File

@@ -3,7 +3,7 @@ import { useCopyText } from '../../hooks/use-copy-text';
import type { ResourceType } from '../../lib/resource-progress';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { ShareIcon } from '../ReactIcons/ShareIcon';
import { cn } from '../../lib/classname';
import { isLoggedIn } from '../../lib/jwt';
type ProgressShareButtonProps = {
resourceId: string;
@@ -11,7 +11,6 @@ type ProgressShareButtonProps = {
className?: string;
shareIconClassName?: string;
checkIconClassName?: string;
isCustomResource?: boolean;
};
export function ProgressShareButton(props: ProgressShareButtonProps) {
const {
@@ -20,7 +19,6 @@ export function ProgressShareButton(props: ProgressShareButtonProps) {
className,
shareIconClassName,
checkIconClassName,
isCustomResource,
} = props;
const user = useAuth();
@@ -32,13 +30,10 @@ export function ProgressShareButton(props: ProgressShareButtonProps) {
isDev ? 'http://localhost:3000' : 'https://roadmap.sh'
);
if (resourceType === 'roadmap' && !isCustomResource) {
if (resourceType === 'roadmap') {
newUrl.pathname = `/${resourceId}`;
} else if (resourceType === 'best-practice' && !isCustomResource) {
newUrl.pathname = `/best-practices/${resourceId}`;
} else {
newUrl.pathname = `/r`;
newUrl.searchParams.set('id', resourceId || '');
newUrl.pathname = `/best-practices/${resourceId}`;
}
newUrl.searchParams.set('s', user?.id || '');
@@ -51,11 +46,9 @@ export function ProgressShareButton(props: ProgressShareButtonProps) {
return (
<button
className={cn(
'flex items-center gap-1 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-70',
isCopied ? 'text-green-500' : 'text-gray-500 hover:text-black',
className
)}
className={`flex items-center gap-1 text-sm font-medium ${
isCopied ? 'text-green-500' : 'text-gray-500 hover:text-black'
} ${className}`}
onClick={handleCopyLink}
>
{isCopied ? (

View File

@@ -11,14 +11,12 @@ import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { useAuth } from '../../hooks/use-auth';
import { Spinner } from '../ReactIcons/Spinner';
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
import { renderFlowJSON } from '../../../renderer/renderer';
export type ProgressMapProps = {
userId?: string;
resourceId: string;
resourceType: ResourceType;
onClose?: () => void;
isCustomResource?: boolean;
};
type UserProgressResponse = {
@@ -40,7 +38,6 @@ export function UserProgressModal(props: ProgressMapProps) {
resourceType,
userId: propUserId,
onClose: onModalClose,
isCustomResource,
} = props;
const { s: userId = propUserId } = getUrlParams();
@@ -65,12 +62,6 @@ export function UserProgressModal(props: ProgressMapProps) {
resourceJsonUrl += `/best-practices/${resourceId}.json`;
}
if (isCustomResource) {
resourceJsonUrl = `${
import.meta.env.PUBLIC_API_URL
}/v1-get-roadmap/${resourceId}`;
}
async function getUserProgress(
userId: string,
resourceType: string,
@@ -97,12 +88,6 @@ export function UserProgressModal(props: ProgressMapProps) {
throw error || new Error('Something went wrong. Please try again!');
}
if (isCustomResource) {
return await renderFlowJSON({
nodes: roadmapJson?.nodes || [],
edges: roadmapJson?.edges || [],
});
}
return await wireframeJSONToSVG(roadmapJson, {
fontURL: '/fonts/balsamiq.woff2',
});
@@ -171,14 +156,6 @@ export function UserProgressModal(props: ProgressMapProps) {
el.removeAttribute('data-group-id');
});
svg.querySelectorAll('[data-node-id]').forEach((el) => {
el.removeAttribute('data-node-id');
});
svg.querySelectorAll('[data-type]').forEach((el) => {
el.removeAttribute('data-type');
});
setResourceSvg(svg);
setProgressResponse(user);
})

View File

@@ -2,11 +2,11 @@
> Choose your image format appropriately
To ensure that your images don't slow your website, choose the format that will correspond to your image. If it's a photo, JPEG is most of the time more appropriate than PNG or GIF. However, don't forget to look at the more modern formats that can reduce the size of your files. Each image format has pros and cons, so it's important to know these to make the best choice possible.
To ensure that your images don't slow your website, choose the format that will correspond to your image. If it's a photo, JPEG is most of the time more appropriate than PNG or GIF. But don't forget to look a the nex-gen formats which can reduce the size of your files. Each image format has pros and cons, it's important to know these to make the best choice possible.
Use [Lighthouse](https://developers.google.com/web/tools/lighthouse/) to identify which images can eventually use modern formats (like JPEG 2000m, JPEG XR or WebP). Compare different formats, sometimes using PNG8 is better than PNG16, sometimes it's not.
Use [Lighthouse](https://developers.google.com/web/tools/lighthouse/) to identify which images can eventually use next-gen formats (like JPEG 2000m JPEG XR or WebP). Compare different formats, sometimes using PNG8 is better than PNG16, sometimes it's not.
- [Serve Images in Modern Formats](https://developers.google.com/web/tools/lighthouse/audits/webp)
- [Serve Images in Next-Gen Formats](https://developers.google.com/web/tools/lighthouse/audits/webp)
- [What Is the Right Image Format for Your Website?](https://www.sitepoint.com/what-is-the-right-image-format-for-your-website/)
- [PNG8 - The Clear Winner](https://www.sitepoint.com/png8-the-clear-winner/)
- [8-bit vs 16-bit - What Color Depth You Should Use And Why It Matters](https://www.diyphotography.net/8-bit-vs-16-bit-color-depth-use-matters/)

View File

@@ -0,0 +1,27 @@
Let's see how we can use the `alert`, `prompt` and `confirm` functions to interact with the user.
## alert()
The `alert()` method displays an alert box with a specified message and an OK button.
```js
alert('Hello World!');
```
## prompt()
The `prompt()` method displays a dialog box that prompts the visitor for input. A prompt box is often used if you want the user to input a value before entering a page. The `prompt()` method returns the input value if the user clicks OK. If the user clicks Cancel, the method returns `null`.
```js
const name = prompt('What is your name?');
console.log(name);
```
## confirm()
The `confirm()` method displays a dialog box with a specified message, along with an OK and a Cancel button. This is often used to confirm or verify something from the user.
```js
const result = confirm('Are you sure?');
console.log(result); // true/false
```

View File

@@ -0,0 +1,32 @@
You can add a new element to the DOM using the `appendChild` or `insertBefore` method.
## appendChild
The `appendChild` method adds a new element as the last child of the specified parent element.
```js
const roadmapWrapper = document.querySelector('.roadmap-wrapper');
const roadmap = document.createElement('div');
roadmap.id = 'javascript-roadmap';
roadmapWrapper.appendChild(roadmapTitle);
```
In the example above, the `roadmap` element is added as the last child of the `roadmapWrapper` element.
## insertBefore
The `insertBefore` method adds a new element before the specified child element.
```js
const roadmapWrapper = document.querySelector('.roadmap-wrapper');
const roadmap = document.createElement('div');
roadmap.id = 'javascript-roadmap';
const roadmapTitle = document.querySelector('#roadmap-title');
roadmapWrapper.insertBefore(roadmap, roadmapTitle);
```
In the example above, the `roadmap` element is added before the `roadmapTitle` element.

View File

@@ -0,0 +1,27 @@
The difference between Asynchronous and Synchronous code is that Asynchronous code does not block the execution of the program while Synchronous code does.
## Asynchronous code
Asynchronous code is executed in the background and it does not block the execution of the program. It is usually used to perform tasks that take a long time to complete, such as network requests.
```js
console.log('Before');
setTimeout(() => {
console.log('Hello');
}, 1000);
console.log('After');
```
## Synchronous code
Synchronous code is executed in sequence and it blocks the execution of the program until it is completed. If a task takes a long time to complete, everything else waits.
```js
console.log('Before');
for (let i = 0; i < 1000000000; i++) {}
console.log('After');
```

View File

@@ -0,0 +1,28 @@
You can use `break` and `continue` in loops to alter the flow of the loop. `break` will stop the loop from continuing, and `continue` will skip the current iteration and continue the loop.
```js
for (let i = 0; i < 5; i++) {
if (i === 1) {
continue; // skips the rest of the code in the loop
}
console.log(`i: ${i}`);
}
// Output:
// i: 0
// i: 2
// i: 3
// i: 4
```
```js
for (let i = 0; i < 5; i++) {
if (i === 1) {
break; // stops the loop
}
console.log(`i: ${i}`);
}
// Output:
// i: 0
```

View File

@@ -0,0 +1,48 @@
**Callback hell**, often referred to as **Pyramid of Doom**, describes a situation in JavaScript where multiple nested callbacks become difficult to manage, leading to unreadable and unmaintainable code. It often arises when performing multiple asynchronous operations that depend on the completion of previous operations. The code starts to take on a pyramidal shape due to the nesting.
## Example of callback hell
```js
callAsync1(function () {
callAsync2(function () {
callAsync3(function () {
callAsync4(function () {
callAsync5(function () {
// ...
});
});
});
});
});
```
## Strategies to avoid callback hell
Developers can address or avoid callback hell by using strategies like modularizing the code into named functions, using asynchronous control flow libraries, or leveraging modern JavaScript features like Promises and `async/await` to write more linear, readable asynchronous code.
### Promise chaining
```js
callAsync1()
.then(() => callAsync2())
.then(() => callAsync3())
.then(() => callAsync4())
.then(() => callAsync5())
.catch((err) => console.error(err));
```
### Async/await
```js
async function asyncCall() {
try {
await callAsync1();
await callAsync2();
await callAsync3();
await callAsync4();
await callAsync5();
} catch (err) {
console.error(err);
}
}
```

View File

@@ -0,0 +1,18 @@
A closure is a function that has access to its outer function scope even after the outer function has returned. This means a closure can remember and access variables and arguments of its outer function even after the function has finished.
```js
function outer() {
const name = 'Roadmap';
function inner() {
console.log(name);
}
return inner;
}
const closure = outer();
closure(); // Roadmap
```
In the above example, the `inner` function has access to the `name` variable of the `outer` function even after the `outer` function has returned. Therefore, the `inner` function forms a closure.

View File

@@ -0,0 +1,8 @@
The Comma Operator `,` evaluates each of its operands (from left to right) and returns the value of the last operand.
```js
let x = 1;
x = (x++, x);
console.log(x); // 2
```

View File

@@ -0,0 +1,9 @@
To create a new DOM element, you can use the `document.createElement` method. It accepts a tag name as an argument and returns a new element with the specified tag name. You can set attributes to the element.
```js
const div = document.createElement('div');
div.id = 'roadmap-wrapper';
div.setAttribute('data-id', 'javascript');
console.log(div); // <div id="roadmap-wrapper" data-id="javascript"></div>
```

View File

@@ -0,0 +1,33 @@
You can use the `CustomEvent` constructor to create a custom event. The `CustomEvent` constructor accepts two arguments: the event name and an optional object that specifies the event options. And you can use the `dispatchEvent` method to dispatch the custom event on the target element/document.
## Creating Custom Events
```js
const event = new CustomEvent('roadmap-updated', {
detail: { name: 'JavaScript' },
});
element.dispatchEvent(event);
```
## Listening for Custom Events
You can listen for custom events using the `addEventListener` method. The `addEventListener` method accepts the event name and a callback function that is called when the event is dispatched.
```js
element.addEventListener('roadmap-updated', (event) => {
console.log(event.detail); // { name: 'JavaScript' }
});
```
## Removing Event Listeners
You can remove event listeners using the `removeEventListener` method. The `removeEventListener` method accepts the event name and the callback function that was used to add the event listener.
```js
function handleEvent(event) {
console.log(event.detail); // { name: 'JavaScript' }
}
element.addEventListener('roadmap-updated', handleEvent);
element.removeEventListener('roadmap-updated', handleEvent);
```

View File

@@ -0,0 +1,38 @@
Debugging JavaScript code can be achieved through various methods and tools. Here's a basic guide:
## Console Logging:
You can use `console.log()`, `console.warn()`, `console.error()`, etc., to print values, variables, or messages to the browser's developer console.
```js
console.log('Value of x:', x);
```
## Browser Developer Tools:
Most modern browsers come equipped with developer tools. You can access these tools by pressing `F12` or right-clicking on the web page and selecting `Inspect` or `Inspect Element`.
- **Sources Tab**: Allows you to see the loaded scripts, set breakpoints, and step through the code.
- **Console Tab**: Displays console outputs and allows for interactive JavaScript execution.
- **Network Tab**: Helps in checking network requests and responses.
## Setting Breakpoints:
In the `Sources` tab of the browser's developer tools, you can click on a line number to set a breakpoint. The code execution will pause at this line, allowing you to inspect variables, the call stack, and continue step-by-step.
## Debugger Statement:
Inserting the `debugger;` statement in your code will act as a breakpoint when the browser developer tools are open. Execution will pause at the `debugger;` line.
```js
function myFunction() {
debugger; // Execution will pause here when dev tools are open
// ... rest of the code
}
```
## Call Stack and Scope:
In the developer tools, when paused on a breakpoint or `debugger;` statement, you can inspect the `call stack` to see the sequence of function calls. The `Scope` panel will show you the values of local and global variables.
Remember, debugging is an iterative process. It often involves setting breakpoints, checking variables, adjusting code, and re-running to ensure correctness.

View File

@@ -0,0 +1,25 @@
The main difference between `defer` and `async` is the order of execution.
## Defer attribute
A `<script>` element with a `defer` attribute, it will continue to load the HTML page and render it while the script is being downloaded. The script is executed after the HTML page has been completely parsed. `defer` scripts maintain their order in the document.
```html
<script defer src="script1.js"></script>
<script defer src="script2.js"></script>
```
In the example above, `script1.js` will be executed before `script2.js`. The browser will download both scripts in parallel, but `script1.js` will be executed after the HTML page has been parsed and `script2.js` will be executed after `script1.js` has been executed.
## Async attribute
On the other hand, A `<script>` element with an `async` attribute, it will pause the HTML parser and execute the script immediately after it has been downloaded. The HTML parsing will resume after the script has been executed.
```html
<script async src="script1.js"></script>
<script async src="script2.js"></script>
```
In the example above, the browser will download both scripts in parallel, and execute them as soon as they are downloaded. The order of execution is not guaranteed.
To know more you can check [this diagram](https://roadmap.sh/guides/avoid-render-blocking-javascript-with-async-defer) from us that explains the difference between `defer` and `async` in a visual way.

View File

@@ -0,0 +1,14 @@
The `do...while` statement creates a loop that executes a block of code once, before checking if the condition is `true`, then it will repeat the loop as long as the condition is `true`.
```js
let i = 0;
do {
console.log(i);
i++;
} while (i < 3);
// 0
// 1
// 2
```

View File

@@ -0,0 +1,7 @@
The `==` equality operator converts the operands if they are not of the same type, then applies strict comparison. The `===` strict equality operator only considers values equal that have the same type.
```js
console.log(1 == '1'); // true
console.log(1 === '1'); // false
console.log(1 === 1); // true
```

View File

@@ -0,0 +1,24 @@
In order to handle errors in async/await, we can use the `try/catch` statement.
## Rejecting a promise
```js
const promise = new Promise((resolve, reject) => {
reject(new Error('Something went wrong'));
});
```
## Try/catch statement
```js
async function main() {
try {
const result = await promise;
console.log(result);
} catch (error) {
console.log(error.message);
}
}
```
The `catch` block will be executed when the promise is `rejected` or when an error is thrown inside the `try` block.

View File

@@ -0,0 +1,38 @@
In order to handle errors in promises, we can use the `catch` method or the second argument of the `then` method.
## Rejecting a promise
```js
const promise = new Promise((resolve, reject) => {
reject(new Error('Something went wrong'));
});
```
## Catch method
In this method, we can pass a `callback` function that will be called when the promise is `rejected`.
```js
promise
.then((result) => {
console.log(result);
})
.catch((error) => {
console.log(error.message);
});
```
## Second argument of the then method
In this method, we can pass two `callback` functions as arguments. The first one will be called when the promise is `resolved` and the second one will be called when the promise is `rejected`.
```js
promise.then(
(result) => {
console.log(result);
},
(error) => {
console.log(error.message);
}
);
```

View File

@@ -0,0 +1,19 @@
Event bubbling is a concept in the Document Object Model (DOM) that describes the way in which events propagate or "bubble up" through the hierarchy of nested elements in the DOM.
When an event, such as a mouse click, occurs on a DOM element, the event will be handled by the element first, then its parent element, and so on, until the event reaches the root element. This behavior is called event bubbling.
```js
const parent = document.querySelector('.parent');
const child = document.querySelector('.child');
// Scenario of clicking on the child element
parent.addEventListener('click', () => {
console.log('Handled Last');
});
child.addEventListener('click', () => {
console.log('Handled First');
});
```
In the above example, when you click on the `child` element, the event will be handled by the `child` element first, then its parent element, and so on, to the root element unless you stop the propagation (`event.stopPropagation()`) of the event.

View File

@@ -0,0 +1,26 @@
The Event loop has two main components: the Call stack and the Callback queue.
## Call Stack
The Call stack is a data structure that stores the tasks that need to be executed. It is a LIFO (Last In, First Out) data structure, which means that the last task that was added to the Call stack will be the first one to be executed.
## Callback Queue
The Callback queue is a data structure that stores the tasks that have been completed and are ready to be executed. It is a FIFO (First In, First Out) data structure, which means that the first task that was added to the Callback queue will be the first one to be executed.
## Event Loop's Workflow:
1. Executes tasks from the Call Stack.
2. For an asynchronous task, such as a timer, it runs in the background. JavaScript proceeds to the next task without waiting.
3. When the asynchronous task concludes, its callback function is added to the Callback Queue.
4. If the Call Stack is empty and there are tasks in the Callback Queue, the Event Loop transfers the first task from the Queue to the Call Stack for execution.
```js
setTimeout(() => console.log('Hello from the timer'), 0);
console.log('Hello from the main code');
```
1. `setTimeout` is processed, and because it's asynchronous, its callback is placed in the Callback Queue.
2. The next line, `console.log("Hello from the main code")`, is logged immediately.
3. Although the timer duration is 0 milliseconds, its callback has to wait until the Call Stack is empty. After the main code logs, the callback is moved from the Callback Queue to the Call Stack and executed.
4. The result is "Hello from the main code" being logged before "Hello from the timer".

View File

@@ -0,0 +1,19 @@
Explicit binding is a way to explicitly state what the `this` keyword is going to be bound to using `call`, `apply` or `bind` methods of a function.
```js
const roadmap = {
name: 'JavaScript',
};
function printName() {
console.log(this.name);
}
printName.call(roadmap); // JavaScript
printName.apply(roadmap); // JavaScript
const printRoadmapName = printName.bind(roadmap);
printRoadmapName(); // JavaScript
```
In the above example, the `this` keyword inside the `printName()` function is explicitly bound to the `roadmap` object using `call`, `apply` or `bind` methods.

View File

@@ -0,0 +1,12 @@
You can use the `filter()` method to filter an array based on a condition. The `filter()` method creates a new array with all elements that pass the test implemented by the provided function.
```js
const numbers = [1, 2, 3, 4, 5, 6];
const evenNumbers = numbers.filter((number) => {
return number % 2 === 0;
});
console.log(numbers); // [1, 2, 3, 4, 5, 6]
console.log(evenNumbers); // [2, 4, 6]
```

View File

@@ -0,0 +1,14 @@
The `finally` block will be executed when the promise is `resolved` or `rejected`.
```js
promise
.then((result) => {
console.log(result);
})
.catch((error) => {
console.log(error.message);
})
.finally(() => {
console.log('Finally Promise has settled');
});
```

View File

@@ -0,0 +1,55 @@
There are serveral ways to find unique values in an array. Here are some of them:
## Using `Set`
```js
const roadmaps = ['JavaScript', 'React', 'Node.js', 'Node.js', 'JavaScript'];
const uniqueRoadmaps = [...new Set(roadmaps)];
console.log(uniqueRoadmaps); // ['JavaScript', 'React', 'Node.js']
```
## Using `filter()`
```js
const roadmaps = ['JavaScript', 'React', 'Node.js', 'Node.js', 'JavaScript'];
const uniqueRoadmaps = roadmaps.filter(
(roadmap, index) => roadmaps.indexOf(roadmap) === index
);
console.log(uniqueRoadmaps); // ['JavaScript', 'React', 'Node.js']
```
## Using `reduce()`
```js
const roadmaps = ['JavaScript', 'React', 'Node.js', 'Node.js', 'JavaScript'];
const uniqueRoadmaps = roadmaps.reduce((unique, roadmap) => {
return unique.includes(roadmap) ? unique : [...unique, roadmap];
}, []);
console.log(uniqueRoadmaps); // ['JavaScript', 'React', 'Node.js']
```
## Using `forEach()`
```js
const roadmaps = ['JavaScript', 'React', 'Node.js', 'Node.js', 'JavaScript'];
const uniqueRoadmaps = [];
roadmaps.forEach((roadmap) => {
if (!uniqueRoadmaps.includes(roadmap)) {
uniqueRoadmaps.push(roadmap);
}
});
console.log(uniqueRoadmaps); // ['JavaScript', 'React', 'Node.js']
```
## Using `for...of`
```js
const roadmaps = ['JavaScript', 'React', 'Node.js', 'Node.js', 'JavaScript'];
const uniqueRoadmaps = [];
for (const roadmap of roadmaps) {
if (!uniqueRoadmaps.includes(roadmap)) {
uniqueRoadmaps.push(roadmap);
}
}
console.log(uniqueRoadmaps); // ['JavaScript', 'React', 'Node.js']
```

View File

@@ -0,0 +1,9 @@
No, the `forEach()` method does not return a new array. It simply calls a provided function on each element in the array.
```js
const roadmaps = ['JavaScript', 'React', 'Node.js'];
roadmaps.forEach((roadmap) => {
console.log(roadmap);
});
```

Some files were not shown because too many files have changed in this diff Show More