mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-13 18:21:57 +08:00
Compare commits
26 Commits
feat/roadm
...
chore/java
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaf5aee2f5 | ||
|
|
aee10fac37 | ||
|
|
c70ee5c5f3 | ||
|
|
b39de5f670 | ||
|
|
802b84ad79 | ||
|
|
ff000c87ed | ||
|
|
1604cb9d8c | ||
|
|
3fab75d44c | ||
|
|
7fb089259d | ||
|
|
6713b059e1 | ||
|
|
e9651c6afe | ||
|
|
96fe0a5439 | ||
|
|
0393a658a7 | ||
|
|
4d0143f137 | ||
|
|
66eff7af70 | ||
|
|
0331e1f782 | ||
|
|
0318fe48e3 | ||
|
|
00ba8a73c1 | ||
|
|
81a9baedd0 | ||
|
|
50e26e4fe2 | ||
|
|
56473b129c | ||
|
|
1dd53d8994 | ||
|
|
1b639c433c | ||
|
|
041facdc61 | ||
|
|
e4d770e256 | ||
|
|
81bbb42e34 |
@@ -1,3 +1,2 @@
|
||||
PUBLIC_API_URL=http://api.roadmap.sh
|
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
||||
PUBLIC_EDITOR_APP_URL=
|
||||
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@@ -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
5
.gitignore
vendored
@@ -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
11028
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
431
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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/*
|
||||
@@ -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' />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -36,7 +36,6 @@ function handleGuest() {
|
||||
'/account/notification',
|
||||
'/account/update-password',
|
||||
'/account/settings',
|
||||
'/account/roadmaps',
|
||||
'/account/road-card',
|
||||
'/account/friends',
|
||||
'/account',
|
||||
|
||||
@@ -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 · {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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">·</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>
|
||||
);
|
||||
}
|
||||
@@ -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">·</span>
|
||||
<span>
|
||||
<span data-progress-learning="">0</span> in progress
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-skipped="">0</span> skipped
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
← Go back to home
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
←<span className="hidden sm:inline"> 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]'
|
||||
|
||||
@@ -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 <span className="text-gray-300">/</span> 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function CustomTeamRoadmap() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function DefaultTeamRoadmap() {
|
||||
return null;
|
||||
}
|
||||
346
src/components/TeamRoadmaps.tsx
Normal file
346
src/components/TeamRoadmaps.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">·</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>
|
||||
);
|
||||
}
|
||||
@@ -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>→</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>→</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>→</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"
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
27
src/data/question-groups/javascript/content/async-vs-sync.md
Normal file
27
src/data/question-groups/javascript/content/async-vs-sync.md
Normal 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');
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
48
src/data/question-groups/javascript/content/callback-hell.md
Normal file
48
src/data/question-groups/javascript/content/callback-hell.md
Normal 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
18
src/data/question-groups/javascript/content/closure.md
Normal file
18
src/data/question-groups/javascript/content/closure.md
Normal 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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
33
src/data/question-groups/javascript/content/custom-event.md
Normal file
33
src/data/question-groups/javascript/content/custom-event.md
Normal 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);
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
14
src/data/question-groups/javascript/content/do-while-loop.md
Normal file
14
src/data/question-groups/javascript/content/do-while-loop.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
```
|
||||
@@ -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.
|
||||
26
src/data/question-groups/javascript/content/event-loop.md
Normal file
26
src/data/question-groups/javascript/content/event-loop.md
Normal 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".
|
||||
@@ -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.
|
||||
12
src/data/question-groups/javascript/content/filter-method.md
Normal file
12
src/data/question-groups/javascript/content/filter-method.md
Normal 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]
|
||||
```
|
||||
@@ -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');
|
||||
});
|
||||
```
|
||||
@@ -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']
|
||||
```
|
||||
@@ -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
Reference in New Issue
Block a user