mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-01-16 13:51:23 +01:00
Allow creating custom roadmaps (#4486)
* wip: custom roadmap renderer * wip: custom roadmap events * wip: roadmap content * wip: svg styles * wip: custom roadmap progress * Render progress * Shortcut progress * Progress Tracking styles * wip: edit and share button * fix: disabled the share button * wip: content links rendering * Fix progress share * Replace disabled with `canShare` * wip: show custom roadmaps * wip: users all roadmaps * fix: create roadmap api * chore: roadmap sidebar icon * wip: content links * Update links color * Create roadmap home * Create Roadmap button * Roadmap type * chore: share progress modal * wip: share roadmap * wip: change visibility * chore: custom roadmap progress in activity * wip: custom roadmap share progress * chore: friend's roadmap * wip: custom roadmap skeleton * chore: roadmap title * Restricted Page * fix: skeleton loading width * Fix create roadmap button * chore: remove user id * chore: pick roadmap and share * chore: open new tab on create roadmap * chore: change share title * chore: use team id from params * chore: team roadmap create modal * chore: create team roadmap * chore: custom roadmap modal * chore: placeholde roadmaps * chore: roadmap hint * chore: visibility label * chore: public roadmap * chore: empty screen * chore: team progress * chore: create roadmap responsive * chore: form error * chore: multi user history * wip: manage custom roadmap * chore: empty roadmap list * chore: custom roadmap visit * chore: shared roadmaps * chore: shared roadmaps * chore: empty screen and topic title * chore: show progress bar * Implement Error in topic details * Add Modal close button * fix: link groups * Refactor roadmap creation * Refactor roadmap creation * Refactor team creation * Refactor team roadmaps * Refactor team creation roadmap selection * Refactor * Refactor team roadmap loading * Refactor team roadmaps * Refactor team roadmaps listing * Refactor Account dropdown * Updates * Refactor Account dropdown * Fix Team name overflow * Change Icon color * Update team dropdown * Minor UI fixes * Fix minor UI * Flicker fix in team dropdown * Roadmap action dropdown with responsiveness * Team roadmaps listing * Update team settings * Team roadmaps listing * fix: remove visibility change * Update roadmap options modal * Add dummy renderer * Add renderer script * Add generate renderer script * Add generate renderer * wip: add share settings * Update * Update UI * Update Minor UI * Fix team issue * Update Personal roadmaps UI * Add Roadmap Secret * Update teams type * Rearrange sections * Change Secret name * Add action button on roadmap detail page --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
parent
d45c8f9cb2
commit
8310671123
@ -1,2 +1,3 @@
|
|||||||
PUBLIC_API_URL=http://api.roadmap.sh
|
PUBLIC_API_URL=http://api.roadmap.sh
|
||||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
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,6 +27,7 @@ jobs:
|
|||||||
pnpm install
|
pnpm install
|
||||||
- name: Generate meta and build
|
- name: Generate meta and build
|
||||||
run: |
|
run: |
|
||||||
|
npm run generate-renderer
|
||||||
npm run build
|
npm run build
|
||||||
touch ./dist/.nojekyll
|
touch ./dist/.nojekyll
|
||||||
echo 'roadmap.sh' > ./dist/CNAME
|
echo 'roadmap.sh' > ./dist/CNAME
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
.idea
|
.idea
|
||||||
|
.temp
|
||||||
|
|
||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
@ -27,3 +28,7 @@ pnpm-debug.log*
|
|||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
tests-examples
|
tests-examples
|
||||||
*.csv
|
*.csv
|
||||||
|
|
||||||
|
/renderer/*
|
||||||
|
!/renderer/index.tsx
|
||||||
|
!/renderer/renderer.ts
|
@ -18,6 +18,7 @@
|
|||||||
"roadmap-content": "node scripts/roadmap-content.cjs",
|
"roadmap-content": "node scripts/roadmap-content.cjs",
|
||||||
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
|
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
|
||||||
"best-practice-content": "node scripts/best-practice-content.cjs",
|
"best-practice-content": "node scripts/best-practice-content.cjs",
|
||||||
|
"generate-renderer": "sh scripts/generate-renderer.sh",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -30,10 +31,12 @@
|
|||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"astro": "^3.0.5",
|
"astro": "^3.0.5",
|
||||||
"astro-compress": "^2.0.8",
|
"astro-compress": "^2.0.8",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
"dracula-prism": "^2.1.13",
|
"dracula-prism": "^2.1.13",
|
||||||
"jose": "^4.14.4",
|
"jose": "^4.14.4",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.274.0",
|
"lucide-react": "^0.274.0",
|
||||||
|
"nanoid": "^4.0.2",
|
||||||
"nanostores": "^0.9.2",
|
"nanostores": "^0.9.2",
|
||||||
"node-html-parser": "^6.1.5",
|
"node-html-parser": "^6.1.5",
|
||||||
"npm-check-updates": "^16.10.12",
|
"npm-check-updates": "^16.10.12",
|
||||||
@ -41,9 +44,11 @@
|
|||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-confetti": "^6.1.0",
|
"react-confetti": "^6.1.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
|
"reactflow": "^11.8.3",
|
||||||
"rehype-external-links": "^2.1.0",
|
"rehype-external-links": "^2.1.0",
|
||||||
"roadmap-renderer": "^1.0.6",
|
"roadmap-renderer": "^1.0.6",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss": "^3.3.3"
|
"tailwindcss": "^3.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
431
pnpm-lock.yaml
generated
431
pnpm-lock.yaml
generated
@ -32,6 +32,9 @@ dependencies:
|
|||||||
astro-compress:
|
astro-compress:
|
||||||
specifier: ^2.0.8
|
specifier: ^2.0.8
|
||||||
version: 2.0.8
|
version: 2.0.8
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0
|
||||||
dracula-prism:
|
dracula-prism:
|
||||||
specifier: ^2.1.13
|
specifier: ^2.1.13
|
||||||
version: 2.1.13
|
version: 2.1.13
|
||||||
@ -44,6 +47,9 @@ dependencies:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.274.0
|
specifier: ^0.274.0
|
||||||
version: 0.274.0(react@18.0.0)
|
version: 0.274.0(react@18.0.0)
|
||||||
|
nanoid:
|
||||||
|
specifier: ^4.0.2
|
||||||
|
version: 4.0.2
|
||||||
nanostores:
|
nanostores:
|
||||||
specifier: ^0.9.2
|
specifier: ^0.9.2
|
||||||
version: 0.9.2
|
version: 0.9.2
|
||||||
@ -65,6 +71,9 @@ dependencies:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.0.0
|
specifier: ^18.0.0
|
||||||
version: 18.0.0(react@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:
|
rehype-external-links:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
@ -74,6 +83,9 @@ dependencies:
|
|||||||
slugify:
|
slugify:
|
||||||
specifier: ^1.6.6
|
specifier: ^1.6.6
|
||||||
version: 1.6.6
|
version: 1.6.6
|
||||||
|
tailwind-merge:
|
||||||
|
specifier: ^1.14.0
|
||||||
|
version: 1.14.0
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^3.3.3
|
specifier: ^3.3.3
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
@ -1056,6 +1068,114 @@ packages:
|
|||||||
config-chain: 1.1.13
|
config-chain: 1.1.13
|
||||||
dev: false
|
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:
|
/@sigstore/bundle@1.1.0:
|
||||||
resolution: {integrity: sha512-PFutXEy0SmQxYI4texPw3dd2KewuNqv7OuK1ZFtY2fM754yhvG2KdgwIhRnoEE2uHdtdGNQ8s0lb94dW9sELog==}
|
resolution: {integrity: sha512-PFutXEy0SmQxYI4texPw3dd2KewuNqv7OuK1ZFtY2fM754yhvG2KdgwIhRnoEE2uHdtdGNQ8s0lb94dW9sELog==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||||
@ -1175,6 +1295,185 @@ packages:
|
|||||||
'@types/css-tree': 2.3.1
|
'@types/css-tree': 2.3.1
|
||||||
dev: false
|
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:
|
/@types/debug@4.1.8:
|
||||||
resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==}
|
resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -1185,6 +1484,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==}
|
resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/geojson@7946.0.10:
|
||||||
|
resolution: {integrity: sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@types/hast@2.3.5:
|
/@types/hast@2.3.5:
|
||||||
resolution: {integrity: sha512-SvQi0L/lNpThgPoleH53cdjB3y9zpLlVjRbqB3rH8hx1jiRSBGAhyjV3H+URFjNVRqt2EdYNrbZE5IsGlNfpRg==}
|
resolution: {integrity: sha512-SvQi0L/lNpThgPoleH53cdjB3y9zpLlVjRbqB3rH8hx1jiRSBGAhyjV3H+URFjNVRqt2EdYNrbZE5IsGlNfpRg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -1766,6 +2069,10 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/classcat@5.0.4:
|
||||||
|
resolution: {integrity: sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/clean-css@5.3.2:
|
/clean-css@5.3.2:
|
||||||
resolution: {integrity: sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==}
|
resolution: {integrity: sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==}
|
||||||
engines: {node: '>= 10.0'}
|
engines: {node: '>= 10.0'}
|
||||||
@ -1991,6 +2298,71 @@ packages:
|
|||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
dev: true
|
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:
|
/debug@4.3.4:
|
||||||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@ -2839,6 +3211,7 @@ packages:
|
|||||||
/iconv-lite@0.6.3:
|
/iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
requiresBuild: true
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
dev: false
|
dev: false
|
||||||
@ -3887,6 +4260,12 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
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:
|
/nanostores@0.9.2:
|
||||||
resolution: {integrity: sha512-wfKlqLGtOYV9+qzGveqDOSWZUBgTeMr/g+JzfV/GofXQ//0wp0cgHF+QBVlmNH/JW9YA9QN+vR6N0vpniPpARA==}
|
resolution: {integrity: sha512-wfKlqLGtOYV9+qzGveqDOSWZUBgTeMr/g+JzfV/GofXQ//0wp0cgHF+QBVlmNH/JW9YA9QN+vR6N0vpniPpARA==}
|
||||||
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
|
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
|
||||||
@ -4670,6 +5049,25 @@ packages:
|
|||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
dev: false
|
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:
|
/read-cache@1.0.0:
|
||||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -4942,6 +5340,7 @@ packages:
|
|||||||
|
|
||||||
/safer-buffer@2.1.2:
|
/safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
requiresBuild: true
|
||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -5354,6 +5753,10 @@ packages:
|
|||||||
picocolors: 1.0.0
|
picocolors: 1.0.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/tailwind-merge@1.14.0:
|
||||||
|
resolution: {integrity: sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/tailwindcss@3.3.3:
|
/tailwindcss@3.3.3:
|
||||||
resolution: {integrity: sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==}
|
resolution: {integrity: sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@ -5683,6 +6086,14 @@ packages:
|
|||||||
xdg-basedir: 5.1.0
|
xdg-basedir: 5.1.0
|
||||||
dev: false
|
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:
|
/util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@ -5912,6 +6323,26 @@ packages:
|
|||||||
resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==}
|
resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==}
|
||||||
dev: false
|
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:
|
/zwitch@2.0.4:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
dev: false
|
dev: false
|
||||||
|
14
renderer/index.tsx
Normal file
14
renderer/index.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export function Renderer(props: any) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 top-0 z-[9999] border bg-white p-5 text-black">
|
||||||
|
<h2 className="mb-2 text-xl font-semibold">Private Component</h2>
|
||||||
|
<p className="mb-4">
|
||||||
|
Renderer is a private component. If you are a collaborator and have
|
||||||
|
access to it. Run the following command:
|
||||||
|
</p>
|
||||||
|
<code className="mt-5 rounded-md bg-gray-800 p-2 text-white">
|
||||||
|
npm run generate-renderer
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
5
renderer/renderer.ts
Normal file
5
renderer/renderer.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export function renderFlowJSON(data: any, options?: any) {
|
||||||
|
console.warn("renderFlowJSON is not implemented");
|
||||||
|
console.warn("run the following command to generate the renderer:");
|
||||||
|
console.warn("> npm run generate-renderer");
|
||||||
|
}
|
31
scripts/generate-renderer.sh
Normal file
31
scripts/generate-renderer.sh
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
#!/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,6 +2,7 @@
|
|||||||
import AstroIcon from './AstroIcon.astro';
|
import AstroIcon from './AstroIcon.astro';
|
||||||
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
|
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
|
||||||
import { SidebarFriendsCounter } from './Friends/SidebarFriendsCounter';
|
import { SidebarFriendsCounter } from './Friends/SidebarFriendsCounter';
|
||||||
|
import { Map } from 'lucide-react';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
activePageId: string;
|
activePageId: string;
|
||||||
@ -26,10 +27,21 @@ const sidebarLinks = [
|
|||||||
href: '/account/friends',
|
href: '/account/friends',
|
||||||
title: 'Friends',
|
title: 'Friends',
|
||||||
id: 'friends',
|
id: 'friends',
|
||||||
|
isNew: false,
|
||||||
|
icon: {
|
||||||
|
glyph: 'users',
|
||||||
|
classes: 'h-4 w-4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/account/roadmaps',
|
||||||
|
title: 'Roadmaps',
|
||||||
|
id: 'roadmaps',
|
||||||
isNew: true,
|
isNew: true,
|
||||||
icon: {
|
icon: {
|
||||||
glyph: 'users',
|
glyph: 'users',
|
||||||
classes: 'h-4 w-4',
|
classes: 'h-4 w-4',
|
||||||
|
component: Map,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -100,10 +112,16 @@ const sidebarLinks = [
|
|||||||
isActive ? 'bg-slate-100' : ''
|
isActive ? 'bg-slate-100' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<AstroIcon
|
{sidebarLink.icon.component ? (
|
||||||
icon={sidebarLink.icon.glyph}
|
<sidebarLink.icon.component
|
||||||
class={`${sidebarLink.icon.classes} mr-2`}
|
className={`${sidebarLink.icon.classes} mr-2`}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<AstroIcon
|
||||||
|
icon={sidebarLink.icon.glyph}
|
||||||
|
class={`${sidebarLink.icon.classes} mr-2`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{sidebarLink.title}
|
{sidebarLink.title}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@ -136,15 +154,20 @@ const sidebarLinks = [
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span class='flex flex-grow items-center'>
|
<span class='flex flex-grow items-center'>
|
||||||
<AstroIcon
|
{sidebarLink.icon.component ? (
|
||||||
icon={sidebarLink.icon.glyph}
|
<sidebarLink.icon.component
|
||||||
class={`${sidebarLink.icon.classes} mr-2`}
|
className={`${sidebarLink.icon.classes} mr-2`}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<AstroIcon
|
||||||
|
icon={sidebarLink.icon.glyph}
|
||||||
|
class={`${sidebarLink.icon.classes} mr-2`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{sidebarLink.title}
|
{sidebarLink.title}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{sidebarLink.isNew &&
|
{sidebarLink.isNew &&
|
||||||
sidebarLink.id !== 'friends' &&
|
|
||||||
!isActive && (
|
!isActive && (
|
||||||
<span class='relative mr-1 flex items-center'>
|
<span class='relative mr-1 flex items-center'>
|
||||||
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
|
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
|
||||||
|
@ -5,6 +5,17 @@ import { ResourceProgress } from './ResourceProgress';
|
|||||||
import { pageProgressMessage } from '../../stores/page';
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
import { EmptyActivity } from './EmptyActivity';
|
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 = {
|
export type ActivityResponse = {
|
||||||
done: {
|
done: {
|
||||||
today: number;
|
today: number;
|
||||||
@ -13,24 +24,9 @@ export type ActivityResponse = {
|
|||||||
learning: {
|
learning: {
|
||||||
today: number;
|
today: number;
|
||||||
total: number;
|
total: number;
|
||||||
roadmaps: {
|
roadmaps: ProgressResponse[];
|
||||||
title: string;
|
bestPractices: ProgressResponse[];
|
||||||
id: string;
|
customs: ProgressResponse[];
|
||||||
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: {
|
streak: {
|
||||||
count: number;
|
count: number;
|
||||||
@ -110,7 +106,8 @@ export function ActivityPage() {
|
|||||||
})
|
})
|
||||||
.map((roadmap) => (
|
.map((roadmap) => (
|
||||||
<ResourceProgress
|
<ResourceProgress
|
||||||
key={roadmap.id}
|
key={roadmap.id}
|
||||||
|
isCustomResource={roadmap.isCustomResource}
|
||||||
doneCount={roadmap.done || 0}
|
doneCount={roadmap.done || 0}
|
||||||
learningCount={roadmap.learning || 0}
|
learningCount={roadmap.learning || 0}
|
||||||
totalCount={roadmap.total || 0}
|
totalCount={roadmap.total || 0}
|
||||||
@ -137,6 +134,8 @@ export function ActivityPage() {
|
|||||||
})
|
})
|
||||||
.map((bestPractice) => (
|
.map((bestPractice) => (
|
||||||
<ResourceProgress
|
<ResourceProgress
|
||||||
|
isCustomResource={bestPractice.isCustomResource}
|
||||||
|
key={bestPractice.id}
|
||||||
doneCount={bestPractice.done || 0}
|
doneCount={bestPractice.done || 0}
|
||||||
totalCount={bestPractice.total || 0}
|
totalCount={bestPractice.total || 0}
|
||||||
learningCount={bestPractice.learning || 0}
|
learningCount={bestPractice.learning || 0}
|
||||||
|
@ -3,6 +3,7 @@ import { getRelativeTimeString } from '../../lib/date';
|
|||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
|
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { getUser } from '../../lib/jwt';
|
||||||
|
|
||||||
type ResourceProgressType = {
|
type ResourceProgressType = {
|
||||||
resourceType: 'roadmap' | 'best-practice';
|
resourceType: 'roadmap' | 'best-practice';
|
||||||
@ -15,14 +16,17 @@ type ResourceProgressType = {
|
|||||||
skippedCount: number;
|
skippedCount: number;
|
||||||
onCleared?: () => void;
|
onCleared?: () => void;
|
||||||
showClearButton?: boolean;
|
showClearButton?: boolean;
|
||||||
|
isCustomResource: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ResourceProgress(props: ResourceProgressType) {
|
export function ResourceProgress(props: ResourceProgressType) {
|
||||||
const { showClearButton = true } = props;
|
const { showClearButton = true, isCustomResource } = props;
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [isClearing, setIsClearing] = useState(false);
|
const [isClearing, setIsClearing] = useState(false);
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
|
|
||||||
|
const userId = getUser()?.id;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
updatedAt,
|
updatedAt,
|
||||||
resourceType,
|
resourceType,
|
||||||
@ -52,8 +56,8 @@ export function ResourceProgress(props: ResourceProgressType) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
|
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`);
|
||||||
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
|
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`);
|
||||||
|
|
||||||
setIsClearing(false);
|
setIsClearing(false);
|
||||||
setIsConfirming(false);
|
setIsConfirming(false);
|
||||||
@ -62,11 +66,15 @@ export function ResourceProgress(props: ResourceProgressType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const url =
|
let url =
|
||||||
resourceType === 'roadmap'
|
resourceType === 'roadmap'
|
||||||
? `/${resourceId}`
|
? `/${resourceId}`
|
||||||
: `/best-practices/${resourceId}`;
|
: `/best-practices/${resourceId}`;
|
||||||
|
|
||||||
|
if (isCustomResource) {
|
||||||
|
url = `/r?id=${resourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
const totalMarked = doneCount + skippedCount;
|
const totalMarked = doneCount + skippedCount;
|
||||||
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
|
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
|
||||||
|
|
||||||
@ -112,6 +120,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
|||||||
<ProgressShareButton
|
<ProgressShareButton
|
||||||
resourceType={resourceType}
|
resourceType={resourceType}
|
||||||
resourceId={resourceId}
|
resourceId={resourceId}
|
||||||
|
isCustomResource={isCustomResource}
|
||||||
className="text-xs font-normal"
|
className="text-xs font-normal"
|
||||||
shareIconClassName="w-2.5 h-2.5 stroke-2"
|
shareIconClassName="w-2.5 h-2.5 stroke-2"
|
||||||
checkIconClassName="w-2.5 h-2.5"
|
checkIconClassName="w-2.5 h-2.5"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||||
import { OptionType, SearchSelector } from './SearchSelector';
|
import { type OptionType, SearchSelector } from './SearchSelector';
|
||||||
import type { PageType } from './CommandMenu/CommandMenu';
|
import type { PageType } from './CommandMenu/CommandMenu';
|
||||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||||
import { httpPut } from '../lib/http';
|
import { httpPut } from '../lib/http';
|
||||||
|
@ -36,6 +36,7 @@ function handleGuest() {
|
|||||||
'/account/notification',
|
'/account/notification',
|
||||||
'/account/update-password',
|
'/account/update-password',
|
||||||
'/account/settings',
|
'/account/settings',
|
||||||
|
'/account/roadmaps',
|
||||||
'/account/road-card',
|
'/account/road-card',
|
||||||
'/account/friends',
|
'/account/friends',
|
||||||
'/account',
|
'/account',
|
||||||
|
@ -1,37 +1,52 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { httpGet, httpPut } from '../../lib/http';
|
import { httpGet, httpPut } from '../../lib/http';
|
||||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
|
||||||
import { pageProgressMessage } from '../../stores/page';
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
import type { TeamDocument } from './CreateTeamForm';
|
|
||||||
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
|
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
|
||||||
import { SelectRoadmapModal } from './SelectRoadmapModal';
|
import { SelectRoadmapModal } from './SelectRoadmapModal';
|
||||||
import { NotDropdown } from './NotDropdown';
|
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';
|
||||||
|
|
||||||
export type TeamResourceConfig = {
|
export type TeamResourceConfig = {
|
||||||
|
isCustomResource: boolean;
|
||||||
|
title: string;
|
||||||
|
visibility?: AllowedRoadmapVisibility;
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
resourceType: string;
|
resourceType: string;
|
||||||
removed: string[];
|
removed: string[];
|
||||||
|
topics?: number;
|
||||||
|
sharedTeamMemberIds: string[];
|
||||||
|
sharedFriendIds: string[];
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
type RoadmapSelectorProps = {
|
type RoadmapSelectorProps = {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
teamResourceConfig: TeamResourceConfig;
|
teamResources: TeamResourceConfig;
|
||||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
setTeamResources: (config: TeamResourceConfig) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||||
const { teamId, teamResourceConfig = [], setTeamResourceConfig } = props;
|
const { teamId, teamResources = [], setTeamResources } = props;
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
|
||||||
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
|
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
|
||||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||||
|
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState<boolean>(false);
|
||||||
|
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
async function loadAllRoadmaps() {
|
async function loadAllRoadmaps() {
|
||||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
toast.error(error.message || 'Something went wrong. Please try again!');
|
||||||
setError(error.message || 'Something went wrong. Please try again!');
|
setError(error.message || 'Something went wrong. Please try again!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -72,7 +87,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTeamResourceConfig(response);
|
setTeamResources(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRemove(resourceId: string) {
|
async function onRemove(resourceId: string) {
|
||||||
@ -106,13 +121,25 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTeamResourceConfig(response);
|
setTeamResources(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAllRoadmaps().finally();
|
loadAllRoadmaps().finally(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function handleCustomRoadmapCreated(roadmap: RoadmapDocument) {
|
||||||
|
const { _id: roadmapId } = roadmap;
|
||||||
|
if (!roadmapId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAllRoadmaps().finally(() => {});
|
||||||
|
addTeamResource(roadmapId).finally(() => {
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{changingRoadmapId && (
|
{changingRoadmapId && (
|
||||||
@ -121,9 +148,9 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
|||||||
resourceId={changingRoadmapId}
|
resourceId={changingRoadmapId}
|
||||||
resourceType={'roadmap'}
|
resourceType={'roadmap'}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
setTeamResourceConfig={setTeamResourceConfig}
|
setTeamResourceConfig={setTeamResources}
|
||||||
defaultRemovedItems={
|
defaultRemovedItems={
|
||||||
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
|
teamResources.find((c) => c.resourceId === changingRoadmapId)
|
||||||
?.removed || []
|
?.removed || []
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -131,7 +158,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
|||||||
{showSelectRoadmapModal && (
|
{showSelectRoadmapModal && (
|
||||||
<SelectRoadmapModal
|
<SelectRoadmapModal
|
||||||
onClose={() => setShowSelectRoadmapModal(false)}
|
onClose={() => setShowSelectRoadmapModal(false)}
|
||||||
teamResourceConfig={teamResourceConfig}
|
teamResourceConfig={teamResources}
|
||||||
allRoadmaps={allRoadmaps}
|
allRoadmaps={allRoadmaps}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
onRoadmapAdd={(roadmapId) => {
|
onRoadmapAdd={(roadmapId) => {
|
||||||
@ -145,72 +172,170 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="my-3 flex items-center gap-4">
|
||||||
<NotDropdown
|
{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"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSelectRoadmapModal(true);
|
setShowSelectRoadmapModal(true);
|
||||||
}}
|
}}
|
||||||
selectedCount={teamResourceConfig.length}
|
>
|
||||||
singularName={'roadmap'}
|
<Map className="h-4 w-4 stroke-[2.5]" />
|
||||||
pluralName={'roadmaps'}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!teamResourceConfig.length && (
|
{!teamResources.length && (
|
||||||
<p className={'mb-3 mt-2 text-base text-gray-400'}>
|
<div className="flex min-h-[240px] flex-col items-center justify-center rounded-lg border">
|
||||||
No roadmaps selected.
|
<Map className="mb-2 h-12 w-12 text-gray-300" />
|
||||||
</p>
|
<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 > 0 && (
|
{teamResources.length > 0 && (
|
||||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 flex-wrap gap-2.5">
|
<div className="mb-3 grid grid-cols-1 flex-wrap gap-2.5 sm:grid-cols-3">
|
||||||
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
|
{teamResources.map(
|
||||||
const roadmapTitle =
|
({
|
||||||
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
|
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}
|
||||||
|
</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>
|
||||||
|
|
||||||
return (
|
{removingRoadmapId === resourceId && (
|
||||||
<div className="flex flex-col items-start rounded-md border border-gray-300">
|
<div
|
||||||
<div className={'w-full px-3 pb-2 pt-4'}>
|
className={
|
||||||
<span className="mb-0.5 block text-base font-medium leading-none text-black">
|
'flex w-full items-center justify-end p-3 text-sm'
|
||||||
{roadmapTitle}
|
}
|
||||||
</span>
|
>
|
||||||
{removedTopics.length > 0 ? (
|
<span className="text-xs text-gray-500">
|
||||||
<span className={'text-xs leading-none text-gray-900'}>
|
Are you sure?{' '}
|
||||||
{removedTopics.length} topic
|
<button
|
||||||
{removedTopics.length > 1 ? 's' : ''} removed
|
onClick={() => onRemove(resourceId)}
|
||||||
</span>
|
className="mx-0.5 text-red-500 underline underline-offset-1"
|
||||||
) : (
|
>
|
||||||
<span className="text-xs italic leading-none text-gray-400/60">
|
Yes
|
||||||
No changes made ..
|
</button>{' '}
|
||||||
</span>
|
<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>
|
||||||
|
);
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -100,12 +100,13 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
|||||||
{roleBasedRoadmaps.length > 0 && (
|
{roleBasedRoadmaps.length > 0 && (
|
||||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||||
{roleBasedRoadmaps.map((roadmap) => {
|
{roleBasedRoadmaps.map((roadmap) => {
|
||||||
const isSelected = !!teamResourceConfig.find(
|
const isSelected = !!teamResourceConfig?.find(
|
||||||
(r) => r.resourceId === roadmap.id
|
(r) => r.resourceId === roadmap.id
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectRoadmapModalItem
|
<SelectRoadmapModalItem
|
||||||
|
key={roadmap.id}
|
||||||
title={roadmap.title}
|
title={roadmap.title}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -131,6 +132,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectRoadmapModalItem
|
<SelectRoadmapModalItem
|
||||||
|
key={roadmap.id}
|
||||||
title={roadmap.title}
|
title={roadmap.title}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -10,13 +10,15 @@ export const validTeamTypes = [
|
|||||||
value: 'company',
|
value: 'company',
|
||||||
label: 'Company',
|
label: 'Company',
|
||||||
icon: BuildingIcon.src,
|
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',
|
value: 'study_group',
|
||||||
label: 'Study Group',
|
label: 'Study Group',
|
||||||
icon: UsersIcon.src,
|
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;
|
] as const;
|
||||||
|
|
||||||
@ -70,10 +72,11 @@ export function Step0(props: Step0Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'flex flex-col sm:flex-row gap-3'}>
|
<div className={'flex flex-col gap-3 sm:flex-row'}>
|
||||||
{validTeamTypes.map((validTeamType) => (
|
{validTeamTypes.map((validTeamType) => (
|
||||||
<button
|
<button
|
||||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 py-12 ${
|
key={validTeamType.value}
|
||||||
|
className={`flex flex-grow flex-col items-center rounded-lg border px-5 pt-12 pb-10 ${
|
||||||
validTeamType.value == selectedTeamType
|
validTeamType.value == selectedTeamType
|
||||||
? 'border-gray-400 bg-gray-100'
|
? 'border-gray-400 bg-gray-100'
|
||||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
||||||
@ -81,6 +84,7 @@ export function Step0(props: Step0Props) {
|
|||||||
onClick={() => setSelectedTeamType(validTeamType.value)}
|
onClick={() => setSelectedTeamType(validTeamType.value)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
key={validTeamType.value}
|
||||||
alt={validTeamType.label}
|
alt={validTeamType.label}
|
||||||
src={validTeamType.icon}
|
src={validTeamType.icon}
|
||||||
className={`mb-3 h-12 w-12 opacity-10 ${
|
className={`mb-3 h-12 w-12 opacity-10 ${
|
||||||
@ -90,7 +94,7 @@ export function Step0(props: Step0Props) {
|
|||||||
<span className="mb-2 block text-2xl font-bold">
|
<span className="mb-2 block text-2xl font-bold">
|
||||||
{validTeamType.label}
|
{validTeamType.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500 leading-[21px]">
|
<span className="text-sm leading-[21px] text-gray-500">
|
||||||
{validTeamType.description}
|
{validTeamType.description}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -100,11 +104,11 @@ export function Step0(props: Step0Props) {
|
|||||||
{/*Error message*/}
|
{/*Error message*/}
|
||||||
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
|
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
|
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
|
||||||
<a
|
<a
|
||||||
href="/account"
|
href="/account"
|
||||||
className={
|
className={
|
||||||
'rounded-md border border-red-400 bg-white px-8 py-2 text-red-500 text-center'
|
'rounded-md border border-red-400 bg-white px-8 py-2 text-center text-red-500'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
@ -221,11 +221,11 @@ export function Step1(props: Step1Props) {
|
|||||||
setTeamSize((e.target as HTMLSelectElement).value as any)
|
setTeamSize((e.target as HTMLSelectElement).value as any)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="" selected>
|
<option value="">
|
||||||
Select team size
|
Select team size
|
||||||
</option>
|
</option>
|
||||||
{validTeamSizes.map((size) => (
|
{validTeamSizes.map((size) => (
|
||||||
<option value={size}>{size} people</option>
|
<option key={size} value={size}>{size} people</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
@ -17,7 +17,9 @@ export function Step2(props: Step2Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="mt-4 flex w-full flex-col">
|
<div className="mt-4 flex w-full flex-col">
|
||||||
<div className="mb-1 mt-2">
|
<div className="mb-1 mt-2">
|
||||||
<h2 className="mb-1 md:mb-1.5 text-lg md:text-2xl font-bold">Select Roadmaps</h2>
|
<h2 className="mb-1 text-lg font-bold md:mb-1.5 md:text-2xl">
|
||||||
|
Select Roadmaps
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
You can always add and customize your roadmaps later.
|
You can always add and customize your roadmaps later.
|
||||||
</p>
|
</p>
|
||||||
@ -25,12 +27,12 @@ export function Step2(props: Step2Props) {
|
|||||||
|
|
||||||
<RoadmapSelector
|
<RoadmapSelector
|
||||||
teamId={team._id!}
|
teamId={team._id!}
|
||||||
teamResourceConfig={teamResourceConfig}
|
teamResources={teamResourceConfig}
|
||||||
setTeamResourceConfig={setTeamResourceConfig}
|
setTeamResources={setTeamResourceConfig}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
|
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
@ -46,8 +48,9 @@ export function Step2(props: Step2Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
|
disabled={teamResourceConfig.length !== 0}
|
||||||
className={
|
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'
|
'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'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Skip for Now
|
Skip for Now
|
||||||
|
@ -178,8 +178,9 @@ export function Step3(props: Step3Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
|
disabled={users.filter((u) => u.email).length !== 0}
|
||||||
className={
|
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'
|
'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'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Skip for Now
|
Skip for Now
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||||
import { Spinner } from '../ReactIcons/Spinner';
|
import { Spinner } from '../ReactIcons/Spinner';
|
||||||
import { httpGet, httpPut } from '../../lib/http';
|
import { httpPut } from '../../lib/http';
|
||||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||||
import '../FrameRenderer/FrameRenderer.css';
|
import '../FrameRenderer/FrameRenderer.css';
|
||||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||||
import { useKeydown } from '../../hooks/use-keydown';
|
import { useKeydown } from '../../hooks/use-keydown';
|
||||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
import { useStore } from '@nanostores/react';
|
|
||||||
import { $currentTeam } from '../../stores/team';
|
|
||||||
|
|
||||||
export type ProgressMapProps = {
|
export type ProgressMapProps = {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
@ -40,8 +38,6 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
|||||||
const [removedItems, setRemovedItems] =
|
const [removedItems, setRemovedItems] =
|
||||||
useState<string[]>(defaultRemovedItems);
|
useState<string[]>(defaultRemovedItems);
|
||||||
|
|
||||||
const currentTeam = useStore($currentTeam);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onTopicClick(e: any) {
|
function onTopicClick(e: any) {
|
||||||
const groupEl = e.target.closest('.clickable-group');
|
const groupEl = e.target.closest('.clickable-group');
|
||||||
@ -69,7 +65,9 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
|||||||
};
|
};
|
||||||
}, [removedItems]);
|
}, [removedItems]);
|
||||||
|
|
||||||
let resourceJsonUrl = 'https://roadmap.sh';
|
let resourceJsonUrl = import.meta.env.DEV
|
||||||
|
? 'http://localhost:3000'
|
||||||
|
: 'https://roadmap.sh';
|
||||||
if (resourceType === 'roadmap') {
|
if (resourceType === 'roadmap') {
|
||||||
resourceJsonUrl += `/${resourceId}.json`;
|
resourceJsonUrl += `/${resourceId}.json`;
|
||||||
} else {
|
} else {
|
||||||
@ -151,11 +149,7 @@ 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="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 className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
|
||||||
<div
|
<div
|
||||||
id={
|
id={'customized-roadmap'}
|
||||||
currentTeam?.type === 'company'
|
|
||||||
? 'customized-roadmap'
|
|
||||||
: 'original-roadmap'
|
|
||||||
}
|
|
||||||
ref={popupBodyEl}
|
ref={popupBodyEl}
|
||||||
className="popup-body relative rounded-lg bg-white shadow"
|
className="popup-body relative rounded-lg bg-white shadow"
|
||||||
>
|
>
|
||||||
|
@ -0,0 +1,53 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,275 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
121
src/components/CustomRoadmap/CustomRoadmap.tsx
Normal file
121
src/components/CustomRoadmap/CustomRoadmap.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
12
src/components/CustomRoadmap/EmptyRoadmap.tsx
Normal file
12
src/components/CustomRoadmap/EmptyRoadmap.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,94 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
238
src/components/CustomRoadmap/PersonalRoadmapList.tsx
Normal file
238
src/components/CustomRoadmap/PersonalRoadmapList.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
111
src/components/CustomRoadmap/ResourceProgressStats.tsx
Normal file
111
src/components/CustomRoadmap/ResourceProgressStats.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
52
src/components/CustomRoadmap/RestrictedPage.tsx
Normal file
52
src/components/CustomRoadmap/RestrictedPage.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
85
src/components/CustomRoadmap/RoadmapActionButton.tsx
Normal file
85
src/components/CustomRoadmap/RoadmapActionButton.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
142
src/components/CustomRoadmap/RoadmapHeader.tsx
Normal file
142
src/components/CustomRoadmap/RoadmapHeader.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
48
src/components/CustomRoadmap/RoadmapHint.tsx
Normal file
48
src/components/CustomRoadmap/RoadmapHint.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
134
src/components/CustomRoadmap/RoadmapListPage.tsx
Normal file
134
src/components/CustomRoadmap/RoadmapListPage.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
53
src/components/CustomRoadmap/RoadmapRenderer.css
Normal file
53
src/components/CustomRoadmap/RoadmapRenderer.css
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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;
|
||||||
|
}
|
177
src/components/CustomRoadmap/RoadmapRenderer.tsx
Normal file
177
src/components/CustomRoadmap/RoadmapRenderer.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
162
src/components/CustomRoadmap/ShareRoadmapModal.tsx
Normal file
162
src/components/CustomRoadmap/ShareRoadmapModal.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
118
src/components/CustomRoadmap/SharedRoadmapList.tsx
Normal file
118
src/components/CustomRoadmap/SharedRoadmapList.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
28
src/components/CustomRoadmap/SkeletonRoadmapHeader.tsx
Normal file
28
src/components/CustomRoadmap/SkeletonRoadmapHeader.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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,13 +1,20 @@
|
|||||||
---
|
---
|
||||||
import FeaturedItem, { FeaturedItemType } from './FeaturedItem.astro';
|
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||||
|
import FeaturedItem, { type FeaturedItemType } from './FeaturedItem.astro';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
featuredItems: FeaturedItemType[];
|
featuredItems: FeaturedItemType[];
|
||||||
heading: string;
|
heading: string;
|
||||||
|
showCreateRoadmap?: boolean;
|
||||||
allowBookmark?: boolean;
|
allowBookmark?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { featuredItems, heading, allowBookmark = true } = Astro.props;
|
const {
|
||||||
|
featuredItems,
|
||||||
|
heading,
|
||||||
|
showCreateRoadmap,
|
||||||
|
allowBookmark = true,
|
||||||
|
} = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class='relative border-b border-b-[#1e293c] py-10 sm:py-14'>
|
<div class='relative border-b border-b-[#1e293c] py-10 sm:py-14'>
|
||||||
@ -32,6 +39,19 @@ const { featuredItems, heading, allowBookmark = true } = Astro.props;
|
|||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
showCreateRoadmap && (
|
||||||
|
<li>
|
||||||
|
<CreateRoadmapButton
|
||||||
|
client:load
|
||||||
|
className='min-h-[54px]'
|
||||||
|
type={
|
||||||
|
heading.toLowerCase().indexOf('role') > -1 ? 'role' : 'skill'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,7 +10,10 @@ import { AddUserIcon } from '../ReactIcons/AddUserIcon';
|
|||||||
|
|
||||||
type FriendProgressItemProps = {
|
type FriendProgressItemProps = {
|
||||||
friend: ListFriendsResponse[0];
|
friend: ListFriendsResponse[0];
|
||||||
onShowResourceProgress: (resourceId: string) => void;
|
onShowResourceProgress: (
|
||||||
|
resourceId: string,
|
||||||
|
isCustomResource?: boolean
|
||||||
|
) => void;
|
||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,7 +55,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
|||||||
onReload();
|
onReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
const roadmaps = (friend.roadmaps || []).sort((a, b) => {
|
const roadmaps = (friend?.roadmaps || []).sort((a, b) => {
|
||||||
return b.done - a.done;
|
return b.done - a.done;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -86,7 +89,12 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
|||||||
{(showAll ? roadmaps : roadmaps.slice(0, 4)).map((progress) => {
|
{(showAll ? roadmaps : roadmaps.slice(0, 4)).map((progress) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => onShowResourceProgress(progress.resourceId)}
|
onClick={() =>
|
||||||
|
onShowResourceProgress(
|
||||||
|
progress.resourceId,
|
||||||
|
progress.isCustomResource
|
||||||
|
)
|
||||||
|
}
|
||||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||||
key={progress.resourceId}
|
key={progress.resourceId}
|
||||||
>
|
>
|
||||||
|
@ -16,6 +16,7 @@ type FriendResourceProgress = {
|
|||||||
title: string;
|
title: string;
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
resourceType: string;
|
resourceType: string;
|
||||||
|
isCustomResource: boolean;
|
||||||
learning: number;
|
learning: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
done: number;
|
done: number;
|
||||||
@ -52,6 +53,7 @@ export function FriendsPage() {
|
|||||||
const [showFriendProgress, setShowFriendProgress] = useState<{
|
const [showFriendProgress, setShowFriendProgress] = useState<{
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
friend: ListFriendsResponse[0];
|
friend: ListFriendsResponse[0];
|
||||||
|
isCustomResource?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@ -120,6 +122,7 @@ export function FriendsPage() {
|
|||||||
resourceId={showFriendProgress.resourceId}
|
resourceId={showFriendProgress.resourceId}
|
||||||
resourceType={'roadmap'}
|
resourceType={'roadmap'}
|
||||||
onClose={() => setShowFriendProgress(undefined)}
|
onClose={() => setShowFriendProgress(undefined)}
|
||||||
|
isCustomResource={showFriendProgress.isCustomResource}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -167,10 +170,11 @@ export function FriendsPage() {
|
|||||||
{filteredFriends.map((friend) => (
|
{filteredFriends.map((friend) => (
|
||||||
<FriendProgressItem
|
<FriendProgressItem
|
||||||
friend={friend}
|
friend={friend}
|
||||||
onShowResourceProgress={(resourceId) => {
|
onShowResourceProgress={(resourceId, isCustomResource) => {
|
||||||
setShowFriendProgress({
|
setShowFriendProgress({
|
||||||
resourceId,
|
resourceId,
|
||||||
friend,
|
friend,
|
||||||
|
isCustomResource,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
key={friend.userId}
|
key={friend.userId}
|
||||||
|
@ -29,12 +29,7 @@ export function SidebarFriendsCounter() {
|
|||||||
|
|
||||||
const pendingCount = friendCounts?.receivedCount || 0;
|
const pendingCount = friendCounts?.receivedCount || 0;
|
||||||
if (!pendingCount) {
|
if (!pendingCount) {
|
||||||
return (
|
return null;
|
||||||
<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 (
|
return (
|
||||||
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { EmptyProgress } from './EmptyProgress';
|
import { EmptyProgress } from './EmptyProgress';
|
||||||
import { httpGet } from '../../lib/http';
|
import { httpGet } from '../../lib/http';
|
||||||
import { HeroRoadmaps } from './HeroRoadmaps';
|
import { HeroRoadmaps } from './HeroRoadmaps';
|
||||||
import {isLoggedIn} from "../../lib/jwt";
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
|
|
||||||
export type UserProgressResponse = {
|
export type UserProgressResponse = {
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
@ -14,6 +14,7 @@ export type UserProgressResponse = {
|
|||||||
skipped: number;
|
skipped: number;
|
||||||
total: number;
|
total: number;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
isCustomResource: boolean;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
function renderProgress(progressList: UserProgressResponse) {
|
function renderProgress(progressList: UserProgressResponse) {
|
||||||
@ -48,6 +49,8 @@ function renderProgress(progressList: UserProgressResponse) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProgressResponse = UserProgressResponse;
|
||||||
|
|
||||||
export function FavoriteRoadmaps() {
|
export function FavoriteRoadmaps() {
|
||||||
const isAuthenticated = isLoggedIn();
|
const isAuthenticated = isLoggedIn();
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@ -56,7 +59,7 @@ export function FavoriteRoadmaps() {
|
|||||||
|
|
||||||
const [isPreparing, setIsPreparing] = useState(true);
|
const [isPreparing, setIsPreparing] = useState(true);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [progress, setProgress] = useState<UserProgressResponse>([]);
|
const [progress, setProgress] = useState<ProgressResponse>([]);
|
||||||
const [containerOpacity, setContainerOpacity] = useState(0);
|
const [containerOpacity, setContainerOpacity] = useState(0);
|
||||||
|
|
||||||
function showProgressContainer() {
|
function showProgressContainer() {
|
||||||
@ -79,10 +82,9 @@ export function FavoriteRoadmaps() {
|
|||||||
async function loadProgress() {
|
async function loadProgress() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const { response: progressList, error } =
|
const { response: progressList, error } = await httpGet<ProgressResponse>(
|
||||||
await httpGet<UserProgressResponse>(
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (error || !progressList) {
|
if (error || !progressList) {
|
||||||
return;
|
return;
|
||||||
@ -111,7 +113,9 @@ export function FavoriteRoadmaps() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasProgress = progress.length > 0;
|
const hasProgress = progress?.length > 0;
|
||||||
|
const customRoadmaps = progress?.filter((p) => p.isCustomResource);
|
||||||
|
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -120,9 +124,14 @@ export function FavoriteRoadmaps() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="container min-h-full">
|
<div className="container min-h-full">
|
||||||
{!isLoading && progress.length == 0 && <EmptyProgress />}
|
{!isLoading && progress?.length == 0 && <EmptyProgress />}
|
||||||
{progress.length > 0 && (
|
{hasProgress && (
|
||||||
<HeroRoadmaps customRoadmaps={[]} progress={progress} isLoading={isLoading} />
|
<HeroRoadmaps
|
||||||
|
showCustomRoadmaps={true}
|
||||||
|
customRoadmaps={customRoadmaps}
|
||||||
|
progress={defaultRoadmaps}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,6 +4,9 @@ import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
|
|||||||
import { Spinner } from '../ReactIcons/Spinner';
|
import { Spinner } from '../ReactIcons/Spinner';
|
||||||
import type { ResourceType } from '../../lib/resource-progress';
|
import type { ResourceType } from '../../lib/resource-progress';
|
||||||
import { MapIcon } from 'lucide-react';
|
import { MapIcon } from 'lucide-react';
|
||||||
|
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||||
|
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
type ProgressRoadmapProps = {
|
type ProgressRoadmapProps = {
|
||||||
url: string;
|
url: string;
|
||||||
@ -73,21 +76,20 @@ export function HeroTitle(props: ProgressTitleProps) {
|
|||||||
|
|
||||||
type ProgressListProps = {
|
type ProgressListProps = {
|
||||||
progress: UserProgressResponse;
|
progress: UserProgressResponse;
|
||||||
showCustomRoadmaps?: boolean;
|
customRoadmaps: UserProgressResponse;
|
||||||
customRoadmaps: any[]; // @fixme implement this
|
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HeroRoadmaps(props: ProgressListProps) {
|
export function HeroRoadmaps(props: ProgressListProps) {
|
||||||
const {
|
const { progress, isLoading = false, customRoadmaps } = props;
|
||||||
progress,
|
|
||||||
isLoading = false,
|
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||||
customRoadmaps = [{} /* @fixme implement this */],
|
|
||||||
showCustomRoadmaps = false,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative pb-12 pt-4 sm:pt-7">
|
<div className="relative pb-12 pt-4 sm:pt-7">
|
||||||
|
{isCreatingRoadmap && (
|
||||||
|
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
|
||||||
|
)}
|
||||||
{
|
{
|
||||||
<HeroTitle
|
<HeroTitle
|
||||||
icon={
|
icon={
|
||||||
@ -118,38 +120,50 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCustomRoadmaps && (
|
<div className="mt-5">
|
||||||
<div className="mt-5">
|
{
|
||||||
{
|
<HeroTitle
|
||||||
<HeroTitle
|
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
|
||||||
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
|
title="Your custom roadmaps"
|
||||||
title="Your custom roadmaps"
|
/>
|
||||||
/>
|
}
|
||||||
}
|
|
||||||
|
|
||||||
{customRoadmaps.length === 0 && (
|
{customRoadmaps.length === 0 && (
|
||||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
|
<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.{' '}
|
You haven't created any custom roadmaps yet.{' '}
|
||||||
<button className="text-gray-500 underline underline-offset-2 hover:text-gray-400">
|
<button
|
||||||
Create one!
|
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
|
||||||
</button>
|
onClick={() => setIsCreatingRoadmap(true)}
|
||||||
</p>
|
>
|
||||||
)}
|
Create one!
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{customRoadmaps.length > 0 && (
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||||
{customRoadmaps.map((customRoadmap) => (
|
{customRoadmaps.map((customRoadmap) => {
|
||||||
<HeroRoadmap
|
return (
|
||||||
resourceId={'343434'}
|
<HeroRoadmap
|
||||||
resourceType={'roadmap'}
|
key={customRoadmap.resourceId}
|
||||||
resourceTitle={'Frontend Roadmap Revised'}
|
resourceId={customRoadmap.resourceId}
|
||||||
percentageDone={50}
|
resourceType={'roadmap'}
|
||||||
url={`/r?${'34343434'}`}
|
resourceTitle={customRoadmap.resourceTitle}
|
||||||
allowFavorite={false}
|
percentageDone={
|
||||||
/>
|
((customRoadmap.skipped + customRoadmap.done) /
|
||||||
))}
|
customRoadmap.total) *
|
||||||
|
100
|
||||||
|
}
|
||||||
|
url={`/r?id=${customRoadmap.resourceId}`}
|
||||||
|
allowFavorite={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<CreateRoadmapButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
46
src/components/Modal.tsx
Normal file
46
src/components/Modal.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
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>
|
<div class='relative hidden' data-auth-required>
|
||||||
<button
|
<button
|
||||||
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'
|
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'
|
||||||
type='button'
|
type='button'
|
||||||
data-account-button
|
data-account-button
|
||||||
>
|
>
|
||||||
<span class='inline-flex items-center gap-1.5'>
|
<span class='inline-flex items-center gap-1.5'>
|
||||||
Account
|
Account <span class="text-gray-300">/</span> Teams
|
||||||
<Icon
|
<Icon
|
||||||
icon='chevron-down'
|
icon='chevron-down'
|
||||||
class='relative top-[0.5px] h-3 w-3 stroke-[3px]'
|
class='relative top-[0.5px] h-3 w-3 stroke-[3px]'
|
||||||
|
52
src/components/Navigation/AccountDropdown.tsx
Normal file
52
src/components/Navigation/AccountDropdown.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
49
src/components/Navigation/AccountDropdownList.tsx
Normal file
49
src/components/Navigation/AccountDropdownList.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
110
src/components/Navigation/DropdownTeamList.tsx
Normal file
110
src/components/Navigation/DropdownTeamList.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
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 Icon from '../AstroIcon.astro';
|
||||||
import AccountDropdown from './AccountDropdown.astro';
|
import { AccountDropdown } from './AccountDropdown';
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||||
@ -24,7 +24,8 @@ import AccountDropdown from './AccountDropdown.astro';
|
|||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li class='hidden lg:inline'>
|
<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>
|
||||||
<li class='hidden lg:inline'>
|
<li class='hidden lg:inline'>
|
||||||
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
|
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
|
||||||
@ -44,7 +45,7 @@ import AccountDropdown from './AccountDropdown.astro';
|
|||||||
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<AccountDropdown />
|
<AccountDropdown client:only="react" />
|
||||||
|
|
||||||
<a
|
<a
|
||||||
data-guest-required
|
data-guest-required
|
||||||
@ -108,6 +109,11 @@ import AccountDropdown from './AccountDropdown.astro';
|
|||||||
Account
|
Account
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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'>
|
<li data-auth-required class='hidden'>
|
||||||
<button
|
<button
|
||||||
data-logout-button
|
data-logout-button
|
||||||
|
64
src/components/ShareOptions/CopyRoadmapLink.tsx
Normal file
64
src/components/ShareOptions/CopyRoadmapLink.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
160
src/components/ShareOptions/ShareFriendList.tsx
Normal file
160
src/components/ShareOptions/ShareFriendList.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
311
src/components/ShareOptions/ShareOptionsModal.tsx
Normal file
311
src/components/ShareOptions/ShareOptionsModal.tsx
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
129
src/components/ShareOptions/ShareOptionsTab.tsx
Normal file
129
src/components/ShareOptions/ShareOptionsTab.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
164
src/components/ShareOptions/ShareTeamMemberList.tsx
Normal file
164
src/components/ShareOptions/ShareTeamMemberList.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
114
src/components/ShareOptions/TransferToTeamList.tsx
Normal file
114
src/components/ShareOptions/TransferToTeamList.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
46
src/components/ShareOptions/UserItem.tsx
Normal file
46
src/components/ShareOptions/UserItem.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
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,3 +1,4 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||||
|
|
||||||
type StepperStep = {
|
type StepperStep = {
|
||||||
@ -15,14 +16,14 @@ export function Stepper(props: StepperProps) {
|
|||||||
const { steps, activeIndex = 0, completeSteps = [] } = props;
|
const { steps, activeIndex = 0, completeSteps = [] } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ol className="flex w-full items-center text-gray-500">
|
<ol className="flex w-full items-center text-gray-500" key="stepper">
|
||||||
{steps.map((step, stepCounter) => {
|
{steps.map((step, stepCounter) => {
|
||||||
const isComplete = completeSteps.includes(stepCounter);
|
const isComplete = completeSteps.includes(stepCounter);
|
||||||
const isActive = activeIndex === stepCounter;
|
const isActive = activeIndex === stepCounter;
|
||||||
const isLast = stepCounter === (steps.length - 1);
|
const isLast = stepCounter === steps.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={stepCounter}>
|
||||||
<li
|
<li
|
||||||
className={`flex items-center ${
|
className={`flex items-center ${
|
||||||
isComplete || isActive ? 'text-black' : 'text-gray-400'
|
isComplete || isActive ? 'text-black' : 'text-gray-400'
|
||||||
@ -43,7 +44,7 @@ export function Stepper(props: StepperProps) {
|
|||||||
<span className={'h-1 w-full'} />
|
<span className={'h-1 w-full'} />
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
|
@ -112,7 +112,7 @@ export function TeamDropdown() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"
|
className="relative flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"
|
||||||
onClick={() => setShowDropdown(!showDropdown)}
|
onClick={() => setShowDropdown(!showDropdown)}
|
||||||
>
|
>
|
||||||
{pendingTeamIds.length > 0 && (
|
{pendingTeamIds.length > 0 && (
|
||||||
|
@ -2,8 +2,6 @@ import { useRef, useState } from 'react';
|
|||||||
import type { TeamMemberDocument } from './TeamMembersPage';
|
import type { TeamMemberDocument } from './TeamMembersPage';
|
||||||
import MoreIcon from '../../icons/more-vertical.svg';
|
import MoreIcon from '../../icons/more-vertical.svg';
|
||||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
|
||||||
import { MailIcon } from '../ReactIcons/MailIcon';
|
|
||||||
|
|
||||||
export function MemberActionDropdown({
|
export function MemberActionDropdown({
|
||||||
member,
|
member,
|
||||||
@ -33,13 +31,6 @@ export function MemberActionDropdown({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
|
||||||
name: 'Delete',
|
|
||||||
handleClick: () => {
|
|
||||||
onDeleteMember();
|
|
||||||
setIsOpen(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(allowUpdateRole
|
...(allowUpdateRole
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@ -73,6 +64,13 @@ export function MemberActionDropdown({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
handleClick: () => {
|
||||||
|
onDeleteMember();
|
||||||
|
setIsOpen(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
@ -109,22 +109,4 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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,12 +11,16 @@ type GroupRoadmapItemProps = {
|
|||||||
|
|
||||||
export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||||
const { onShowResourceProgress } = props;
|
const { onShowResourceProgress } = props;
|
||||||
const { members, resourceTitle, resourceId } = props.roadmap;
|
const { members, resourceTitle, resourceId, isCustomResource } =
|
||||||
|
props.roadmap;
|
||||||
|
|
||||||
const { t: teamId } = getUrlParams();
|
const { t: teamId } = getUrlParams();
|
||||||
const user = useAuth();
|
const user = useAuth();
|
||||||
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const roadmapLink = isCustomResource
|
||||||
|
? `/r?id=${resourceId}`
|
||||||
|
: `/${resourceId}?t=${teamId}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -25,7 +29,7 @@ export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
|||||||
<div className="flex min-w-0 flex-grow items-center justify-between">
|
<div className="flex min-w-0 flex-grow items-center justify-between">
|
||||||
<h3 className="truncate font-medium">{resourceTitle}</h3>
|
<h3 className="truncate font-medium">{resourceTitle}</h3>
|
||||||
<a
|
<a
|
||||||
href={`/${resourceId}?t=${teamId}`}
|
href={roadmapLink}
|
||||||
className="group mb-0.5 flex shrink-0 items-center justify-between text-base font-medium leading-none text-black"
|
className="group mb-0.5 flex shrink-0 items-center justify-between text-base font-medium leading-none text-black"
|
||||||
target={'_blank'}
|
target={'_blank'}
|
||||||
>
|
>
|
||||||
|
@ -3,7 +3,10 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
type MemberProgressItemProps = {
|
type MemberProgressItemProps = {
|
||||||
member: TeamMember;
|
member: TeamMember;
|
||||||
onShowResourceProgress: (resourceId: string) => void;
|
onShowResourceProgress: (
|
||||||
|
resourceId: string,
|
||||||
|
isCustomResource: boolean
|
||||||
|
) => void;
|
||||||
isMyProgress?: boolean;
|
isMyProgress?: boolean;
|
||||||
};
|
};
|
||||||
export function MemberProgressItem(props: MemberProgressItemProps) {
|
export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||||
@ -29,7 +32,7 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
|||||||
: '/images/default-avatar.png'
|
: '/images/default-avatar.png'
|
||||||
}
|
}
|
||||||
alt={member.name || ''}
|
alt={member.name || ''}
|
||||||
className="min-w-[32px] min-h-[32px] h-8 w-8 rounded-full"
|
className="h-8 min-h-[32px] w-8 min-w-[32px] rounded-full"
|
||||||
/>
|
/>
|
||||||
<div className="inline-grid w-full">
|
<div className="inline-grid w-full">
|
||||||
{!isMyProgress && (
|
{!isMyProgress && (
|
||||||
@ -51,7 +54,12 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
|||||||
(progress) => {
|
(progress) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => onShowResourceProgress(progress.resourceId)}
|
onClick={() =>
|
||||||
|
onShowResourceProgress(
|
||||||
|
progress.resourceId,
|
||||||
|
progress.isCustomResource!
|
||||||
|
)
|
||||||
|
}
|
||||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||||
key={progress.resourceId}
|
key={progress.resourceId}
|
||||||
>
|
>
|
||||||
|
@ -18,6 +18,11 @@ import { useAuth } from '../../hooks/use-auth';
|
|||||||
import { pageProgressMessage } from '../../stores/page';
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import { $currentTeam } from '../../stores/team';
|
import { $currentTeam } from '../../stores/team';
|
||||||
|
import { renderFlowJSON } from '../../../renderer/renderer';
|
||||||
|
import {
|
||||||
|
allowedClickableNodeTypes,
|
||||||
|
getNodeDetails,
|
||||||
|
} from '../CustomRoadmap/RoadmapRenderer';
|
||||||
|
|
||||||
export type ProgressMapProps = {
|
export type ProgressMapProps = {
|
||||||
member: TeamMember;
|
member: TeamMember;
|
||||||
@ -26,6 +31,7 @@ export type ProgressMapProps = {
|
|||||||
resourceType: 'roadmap' | 'best-practice';
|
resourceType: 'roadmap' | 'best-practice';
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onShowMyProgress: () => void;
|
onShowMyProgress: () => void;
|
||||||
|
isCustomResource?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MemberProgressResponse = {
|
type MemberProgressResponse = {
|
||||||
@ -43,10 +49,10 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
onShowMyProgress,
|
onShowMyProgress,
|
||||||
teamId,
|
teamId,
|
||||||
onClose,
|
onClose,
|
||||||
|
isCustomResource,
|
||||||
} = props;
|
} = props;
|
||||||
const user = useAuth();
|
const user = useAuth();
|
||||||
const isCurrentUser = user?.email === member.email;
|
const isCurrentUser = user?.email === member.email;
|
||||||
const currentTeam = useStore($currentTeam);
|
|
||||||
|
|
||||||
const containerEl = useRef<HTMLDivElement>(null);
|
const containerEl = useRef<HTMLDivElement>(null);
|
||||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||||
@ -64,6 +70,12 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCustomResource) {
|
||||||
|
resourceJsonUrl = `${
|
||||||
|
import.meta.env.PUBLIC_API_URL
|
||||||
|
}/v1-get-roadmap/${resourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getMemberProgress(
|
async function getMemberProgress(
|
||||||
teamId: string,
|
teamId: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
@ -86,11 +98,28 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderResource(jsonUrl: string) {
|
async function renderResource(jsonUrl: string) {
|
||||||
const res = await fetch(jsonUrl);
|
const res = await fetch(jsonUrl, {
|
||||||
const json = await res.json();
|
...(isCustomResource && {
|
||||||
const svg = await wireframeJSONToSVG(json, {
|
credentials: 'include',
|
||||||
fontURL: '/fonts/balsamiq.woff2',
|
}),
|
||||||
});
|
});
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
containerEl.current?.replaceChildren(svg);
|
containerEl.current?.replaceChildren(svg);
|
||||||
}
|
}
|
||||||
@ -186,9 +215,28 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
let topicId = '';
|
||||||
if (!groupId) {
|
if (isCustomResource) {
|
||||||
return;
|
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+-/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetGroup.classList.contains('removed')) {
|
if (targetGroup.classList.contains('removed')) {
|
||||||
@ -197,13 +245,9 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const isCurrentStatusDone = targetGroup.classList.contains('done');
|
const isCurrentStatusDone = targetGroup?.classList.contains('done');
|
||||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
|
||||||
|
|
||||||
updateTopicStatus(
|
updateTopicStatus(topicId, !isCurrentStatusDone ? 'done' : 'pending');
|
||||||
normalizedGroupId,
|
|
||||||
!isCurrentStatusDone ? 'done' : 'pending'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClick(e: MouseEvent) {
|
async function handleClick(e: MouseEvent) {
|
||||||
@ -211,9 +255,28 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
if (!targetGroup) {
|
if (!targetGroup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
let topicId = '';
|
||||||
if (!groupId) {
|
if (isCustomResource) {
|
||||||
return;
|
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+-/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetGroup.classList.contains('removed')) {
|
if (targetGroup.classList.contains('removed')) {
|
||||||
@ -221,15 +284,13 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
|
||||||
|
|
||||||
const isCurrentStatusLearning = targetGroup.classList.contains('learning');
|
const isCurrentStatusLearning = targetGroup.classList.contains('learning');
|
||||||
const isCurrentStatusSkipped = targetGroup.classList.contains('skipped');
|
const isCurrentStatusSkipped = targetGroup.classList.contains('skipped');
|
||||||
|
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
updateTopicStatus(
|
updateTopicStatus(
|
||||||
normalizedGroupId,
|
topicId,
|
||||||
!isCurrentStatusLearning ? 'learning' : 'pending'
|
!isCurrentStatusLearning ? 'learning' : 'pending'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@ -238,7 +299,7 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
if (e.altKey) {
|
if (e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
updateTopicStatus(
|
updateTopicStatus(
|
||||||
normalizedGroupId,
|
topicId,
|
||||||
!isCurrentStatusSkipped ? 'skipped' : 'pending'
|
!isCurrentStatusSkipped ? 'skipped' : 'pending'
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -279,7 +340,7 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||||
<div
|
<div
|
||||||
id={currentTeam?.type === 'company' ? 'customized-roadmap' : 'original-roadmap'}
|
id={isCustomResource ? 'original-roadmap' : 'customized-roadmap'}
|
||||||
className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto"
|
className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -392,7 +453,7 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="resource-svg-wrap"
|
id={'resource-svg-wrap'}
|
||||||
ref={containerEl}
|
ref={containerEl}
|
||||||
className="px-4 pb-2"
|
className="px-4 pb-2"
|
||||||
></div>
|
></div>
|
||||||
|
@ -20,6 +20,7 @@ export type UserProgress = {
|
|||||||
skipped: number;
|
skipped: number;
|
||||||
total: number;
|
total: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
isCustomResource?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TeamMember = {
|
export type TeamMember = {
|
||||||
@ -36,6 +37,7 @@ export type GroupByRoadmap = {
|
|||||||
resourceId: string;
|
resourceId: string;
|
||||||
resourceTitle: string;
|
resourceTitle: string;
|
||||||
resourceType: string;
|
resourceType: string;
|
||||||
|
isCustomResource?: boolean;
|
||||||
members: {
|
members: {
|
||||||
member: TeamMember;
|
member: TeamMember;
|
||||||
progress: UserProgress | undefined;
|
progress: UserProgress | undefined;
|
||||||
@ -58,6 +60,7 @@ export function TeamProgressPage() {
|
|||||||
const [showMemberProgress, setShowMemberProgress] = useState<{
|
const [showMemberProgress, setShowMemberProgress] = useState<{
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
member: TeamMember;
|
member: TeamMember;
|
||||||
|
isCustomResource?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||||
@ -108,6 +111,7 @@ export function TeamProgressPage() {
|
|||||||
|
|
||||||
const groupByRoadmap: GroupByRoadmap[] = [];
|
const groupByRoadmap: GroupByRoadmap[] = [];
|
||||||
for (const roadmap of currentTeam?.roadmaps || []) {
|
for (const roadmap of currentTeam?.roadmaps || []) {
|
||||||
|
let isCustomResource = false;
|
||||||
const members: GroupByRoadmap['members'] = [];
|
const members: GroupByRoadmap['members'] = [];
|
||||||
for (const member of teamMembers) {
|
for (const member of teamMembers) {
|
||||||
const progress = member.progress.find(
|
const progress = member.progress.find(
|
||||||
@ -116,6 +120,10 @@ export function TeamProgressPage() {
|
|||||||
if (!progress) {
|
if (!progress) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (progress.isCustomResource && !isCustomResource) {
|
||||||
|
isCustomResource = true;
|
||||||
|
}
|
||||||
|
|
||||||
members.push({
|
members.push({
|
||||||
member,
|
member,
|
||||||
progress,
|
progress,
|
||||||
@ -131,6 +139,7 @@ export function TeamProgressPage() {
|
|||||||
resourceTitle: members?.[0].progress?.resourceTitle || '',
|
resourceTitle: members?.[0].progress?.resourceTitle || '',
|
||||||
resourceType: 'roadmap',
|
resourceType: 'roadmap',
|
||||||
members,
|
members,
|
||||||
|
isCustomResource,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,6 +160,7 @@ export function TeamProgressPage() {
|
|||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
resourceId={showMemberProgress.resourceId}
|
resourceId={showMemberProgress.resourceId}
|
||||||
resourceType={'roadmap'}
|
resourceType={'roadmap'}
|
||||||
|
isCustomResource={showMemberProgress.isCustomResource}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowMemberProgress(undefined);
|
setShowMemberProgress(undefined);
|
||||||
}}
|
}}
|
||||||
@ -160,6 +170,7 @@ export function TeamProgressPage() {
|
|||||||
member: teamMembers.find(
|
member: teamMembers.find(
|
||||||
(member) => member.email === user?.email
|
(member) => member.email === user?.email
|
||||||
)!,
|
)!,
|
||||||
|
isCustomResource: showMemberProgress.isCustomResource,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -193,6 +204,7 @@ export function TeamProgressPage() {
|
|||||||
setShowMemberProgress({
|
setShowMemberProgress({
|
||||||
resourceId,
|
resourceId,
|
||||||
member,
|
member,
|
||||||
|
isCustomResource: roadmap.isCustomResource,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -207,10 +219,11 @@ export function TeamProgressPage() {
|
|||||||
key={member._id}
|
key={member._id}
|
||||||
member={member}
|
member={member}
|
||||||
isMyProgress={member?.email === user?.email}
|
isMyProgress={member?.email === user?.email}
|
||||||
onShowResourceProgress={(resourceId) => {
|
onShowResourceProgress={(resourceId, isCustomResource) => {
|
||||||
setShowMemberProgress({
|
setShowMemberProgress({
|
||||||
resourceId,
|
resourceId,
|
||||||
member,
|
member,
|
||||||
|
isCustomResource,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
3
src/components/TeamRoadmap/CustomTeamRoadmap.tsx
Normal file
3
src/components/TeamRoadmap/CustomTeamRoadmap.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function CustomTeamRoadmap() {
|
||||||
|
return null;
|
||||||
|
}
|
3
src/components/TeamRoadmap/DefaultTeamRoadmap.tsx
Normal file
3
src/components/TeamRoadmap/DefaultTeamRoadmap.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function DefaultTeamRoadmap() {
|
||||||
|
return null;
|
||||||
|
}
|
@ -1,346 +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 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>
|
|
||||||
);
|
|
||||||
}
|
|
39
src/components/TeamRoadmaps/PickRoadmapOptionModal.tsx
Normal file
39
src/components/TeamRoadmaps/PickRoadmapOptionModal.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
93
src/components/TeamRoadmapsList/RoadmapActionDropdown.tsx
Normal file
93
src/components/TeamRoadmapsList/RoadmapActionDropdown.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
636
src/components/TeamRoadmapsList/TeamRoadmaps.tsx
Normal file
636
src/components/TeamRoadmapsList/TeamRoadmaps.tsx
Normal file
@ -0,0 +1,636 @@
|
|||||||
|
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,21 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import ChevronDown from '../icons/dropdown.svg';
|
|
||||||
import { httpGet } from '../lib/http';
|
import { httpGet } from '../lib/http';
|
||||||
import { useTeamId } from '../hooks/use-team-id';
|
|
||||||
import { useAuth } from '../hooks/use-auth';
|
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 { pageProgressMessage } from '../stores/page';
|
||||||
import { useToast } from '../hooks/use-toast';
|
import { useToast } from '../hooks/use-toast';
|
||||||
|
import { type UserTeamItem } from './TeamDropdown/TeamDropdown';
|
||||||
type TeamListResponse = TeamDocument[];
|
|
||||||
|
|
||||||
export function TeamsList() {
|
export function TeamsList() {
|
||||||
const [teamList, setTeamList] = useState<TeamDocument[]>([]);
|
const [teamList, setTeamList] = useState<UserTeamItem[]>([]);
|
||||||
const user = useAuth();
|
const user = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
async function getAllTeam() {
|
async function getAllTeam() {
|
||||||
const { response, error } = await httpGet<TeamListResponse>(
|
const { response, error } = await httpGet<UserTeamItem[]>(
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||||
);
|
);
|
||||||
if (error || !response) {
|
if (error || !response) {
|
||||||
@ -64,30 +59,39 @@ export function TeamsList() {
|
|||||||
<span>→</span>
|
<span>→</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{teamList.map((team) => (
|
{teamList.map((team) => {
|
||||||
<li key={team._id}>
|
let pageLink = '';
|
||||||
<a
|
if (team.status === 'invited') {
|
||||||
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"
|
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||||
href={`/team/progress?t=${team._id}`}
|
} else if (team.status === 'joined') {
|
||||||
>
|
pageLink = `/team/progress?t=${team._id}`;
|
||||||
<span className="flex flex-grow items-center gap-2">
|
}
|
||||||
<img
|
|
||||||
src={
|
return (
|
||||||
team.avatar
|
<li key={team._id}>
|
||||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
<a
|
||||||
team.avatar
|
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}
|
||||||
: '/images/default-avatar.png'
|
>
|
||||||
}
|
<span className="flex flex-grow items-center gap-2">
|
||||||
alt={team.name || ''}
|
<img
|
||||||
className="h-6 w-6 rounded-full"
|
src={
|
||||||
/>
|
team.avatar
|
||||||
<span className="truncate">{team.name}</span>
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||||
</span>
|
team.avatar
|
||||||
<span>→</span>
|
}`
|
||||||
</a>
|
: '/images/default-avatar.png'
|
||||||
</li>
|
}
|
||||||
))}
|
alt={team.name || ''}
|
||||||
|
className="h-6 w-6 rounded-full"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{team.name}</span>
|
||||||
|
</span>
|
||||||
|
<span>→</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<a
|
<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"
|
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={() => {
|
onClick={() => {
|
||||||
$toastMessage.set(undefined);
|
$toastMessage.set(undefined);
|
||||||
}}
|
}}
|
||||||
className={`fixed bottom-5 left-1/2 z-50 min-w-[375px] max-w-[375px] animate-fade-slide-up sm:min-w-[auto]`}
|
className={`fixed bottom-5 left-1/2 z-[9999] min-w-[375px] max-w-[375px] animate-fade-slide-up sm:min-w-[auto]`}
|
||||||
>
|
>
|
||||||
<div
|
<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`}
|
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`}
|
||||||
|
61
src/components/Tooltip.tsx
Normal file
61
src/components/Tooltip.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
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,16 +20,42 @@ import { TopicProgressButton } from './TopicProgressButton';
|
|||||||
import { ContributionForm } from './ContributionForm';
|
import { ContributionForm } from './ContributionForm';
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
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 [contributionAlertMessage, setContributionAlertMessage] = useState('');
|
||||||
const [isActive, setIsActive] = useState(false);
|
const [isActive, setIsActive] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isContributing, setIsContributing] = useState(false);
|
const [isContributing, setIsContributing] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [topicHtml, setTopicHtml] = useState('');
|
const [topicHtml, setTopicHtml] = useState('');
|
||||||
|
const [topicTitle, setTopicTitle] = useState('');
|
||||||
|
const [links, setLinks] = useState<RoadmapContentDocument['links']>([]);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
const { secret } = getUrlParams() as { secret: string };
|
||||||
const isGuest = useMemo(() => !isLoggedIn(), []);
|
const isGuest = useMemo(() => !isLoggedIn(), []);
|
||||||
const topicRef = useRef<HTMLDivElement>(null);
|
const topicRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -89,7 +115,8 @@ export function TopicDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load the topic detail when the topic detail is active
|
// Load the topic detail when the topic detail is active
|
||||||
useLoadTopic(({ topicId, resourceType, resourceId }) => {
|
useLoadTopic(({ topicId, resourceType, resourceId, isCustomResource }) => {
|
||||||
|
setError('');
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
sponsorHidden.set(true);
|
sponsorHidden.set(true);
|
||||||
@ -100,30 +127,53 @@ export function TopicDetail() {
|
|||||||
setResourceId(resourceId);
|
setResourceId(resourceId);
|
||||||
|
|
||||||
const topicPartial = topicId.replaceAll(':', '/');
|
const topicPartial = topicId.replaceAll(':', '/');
|
||||||
const topicUrl =
|
let topicUrl =
|
||||||
resourceType === 'roadmap'
|
resourceType === 'roadmap'
|
||||||
? `/${resourceId}/${topicPartial}`
|
? `/${resourceId}/${topicPartial}`
|
||||||
: `/best-practices/${resourceId}/${topicPartial}`;
|
: `/best-practices/${resourceId}/${topicPartial}`;
|
||||||
|
|
||||||
httpGet<string>(
|
if (isCustomResource) {
|
||||||
|
topicUrl = `${
|
||||||
|
import.meta.env.PUBLIC_API_URL
|
||||||
|
}/v1-get-node-content/${resourceId}/${topicId}${
|
||||||
|
secret ? `?secret=${secret}` : ''
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
httpGet<string | RoadmapContentDocument>(
|
||||||
topicUrl,
|
topicUrl,
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
headers: {
|
...(!isCustomResource && {
|
||||||
Accept: 'text/html',
|
headers: {
|
||||||
},
|
Accept: 'text/html',
|
||||||
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(({ response }) => {
|
.then(({ response }) => {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
setError('Topic not found.');
|
setError('Topic not found.');
|
||||||
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let topicHtml = '';
|
||||||
// It's full HTML with page body, head etc.
|
if (!isCustomResource) {
|
||||||
// We only need the inner HTML of the #main-content
|
// It's full HTML with page body, head etc.
|
||||||
const node = new DOMParser().parseFromString(response, 'text/html');
|
// We only need the inner HTML of the #main-content
|
||||||
const topicHtml = node?.getElementById('main-content')?.outerHTML || '';
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setTopicHtml(topicHtml);
|
setTopicHtml(topicHtml);
|
||||||
@ -138,8 +188,10 @@ export function TopicDetail() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasContent = topicHtml?.length > 0 || links?.length > 0 || topicTitle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={'relative z-50'}>
|
||||||
<div
|
<div
|
||||||
ref={topicRef}
|
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"
|
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"
|
||||||
@ -197,35 +249,96 @@ export function TopicDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Topic Content */}
|
{/* Topic Content */}
|
||||||
<div
|
{hasContent ? (
|
||||||
id="topic-content"
|
<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">
|
||||||
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>}
|
||||||
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
<div
|
||||||
></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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contribution */}
|
{/* Contribution */}
|
||||||
<div className="mt-8 flex-1 border-t">
|
{canSubmitContribution && (
|
||||||
<p className="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
|
<div className="mt-8 flex-1 border-t">
|
||||||
Help others learn by submitting links to learn more about this
|
<p className="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
|
||||||
topic{' '}
|
Help others learn by submitting links to learn more about this
|
||||||
</p>
|
topic{' '}
|
||||||
<button
|
</p>
|
||||||
onClick={() => {
|
<button
|
||||||
if (isGuest) {
|
onClick={() => {
|
||||||
setIsActive(false);
|
if (isGuest) {
|
||||||
showLoginPopup();
|
setIsActive(false);
|
||||||
return;
|
showLoginPopup();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsContributing(true);
|
setIsContributing(true);
|
||||||
}}
|
}}
|
||||||
disabled={!!contributionAlertMessage}
|
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"
|
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
|
||||||
? contributionAlertMessage
|
? contributionAlertMessage
|
||||||
: 'Submit a Link'}
|
: 'Submit a Link'}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -3,7 +3,7 @@ import { useCopyText } from '../../hooks/use-copy-text';
|
|||||||
import type { ResourceType } from '../../lib/resource-progress';
|
import type { ResourceType } from '../../lib/resource-progress';
|
||||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||||
import { ShareIcon } from '../ReactIcons/ShareIcon';
|
import { ShareIcon } from '../ReactIcons/ShareIcon';
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
import { cn } from '../../lib/classname';
|
||||||
|
|
||||||
type ProgressShareButtonProps = {
|
type ProgressShareButtonProps = {
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
@ -11,6 +11,7 @@ type ProgressShareButtonProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
shareIconClassName?: string;
|
shareIconClassName?: string;
|
||||||
checkIconClassName?: string;
|
checkIconClassName?: string;
|
||||||
|
isCustomResource?: boolean;
|
||||||
};
|
};
|
||||||
export function ProgressShareButton(props: ProgressShareButtonProps) {
|
export function ProgressShareButton(props: ProgressShareButtonProps) {
|
||||||
const {
|
const {
|
||||||
@ -19,6 +20,7 @@ export function ProgressShareButton(props: ProgressShareButtonProps) {
|
|||||||
className,
|
className,
|
||||||
shareIconClassName,
|
shareIconClassName,
|
||||||
checkIconClassName,
|
checkIconClassName,
|
||||||
|
isCustomResource,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const user = useAuth();
|
const user = useAuth();
|
||||||
@ -30,10 +32,13 @@ export function ProgressShareButton(props: ProgressShareButtonProps) {
|
|||||||
isDev ? 'http://localhost:3000' : 'https://roadmap.sh'
|
isDev ? 'http://localhost:3000' : 'https://roadmap.sh'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (resourceType === 'roadmap') {
|
if (resourceType === 'roadmap' && !isCustomResource) {
|
||||||
newUrl.pathname = `/${resourceId}`;
|
newUrl.pathname = `/${resourceId}`;
|
||||||
} else {
|
} else if (resourceType === 'best-practice' && !isCustomResource) {
|
||||||
newUrl.pathname = `/best-practices/${resourceId}`;
|
newUrl.pathname = `/best-practices/${resourceId}`;
|
||||||
|
} else {
|
||||||
|
newUrl.pathname = `/r`;
|
||||||
|
newUrl.searchParams.set('id', resourceId || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
newUrl.searchParams.set('s', user?.id || '');
|
newUrl.searchParams.set('s', user?.id || '');
|
||||||
@ -46,9 +51,11 @@ export function ProgressShareButton(props: ProgressShareButtonProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`flex items-center gap-1 text-sm font-medium ${
|
className={cn(
|
||||||
isCopied ? 'text-green-500' : 'text-gray-500 hover:text-black'
|
'flex items-center gap-1 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-70',
|
||||||
} ${className}`}
|
isCopied ? 'text-green-500' : 'text-gray-500 hover:text-black',
|
||||||
|
className
|
||||||
|
)}
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
|
@ -11,12 +11,14 @@ import { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
|||||||
import { useAuth } from '../../hooks/use-auth';
|
import { useAuth } from '../../hooks/use-auth';
|
||||||
import { Spinner } from '../ReactIcons/Spinner';
|
import { Spinner } from '../ReactIcons/Spinner';
|
||||||
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
||||||
|
import { renderFlowJSON } from '../../../renderer/renderer';
|
||||||
|
|
||||||
export type ProgressMapProps = {
|
export type ProgressMapProps = {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
resourceType: ResourceType;
|
resourceType: ResourceType;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
isCustomResource?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserProgressResponse = {
|
type UserProgressResponse = {
|
||||||
@ -38,6 +40,7 @@ export function UserProgressModal(props: ProgressMapProps) {
|
|||||||
resourceType,
|
resourceType,
|
||||||
userId: propUserId,
|
userId: propUserId,
|
||||||
onClose: onModalClose,
|
onClose: onModalClose,
|
||||||
|
isCustomResource,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { s: userId = propUserId } = getUrlParams();
|
const { s: userId = propUserId } = getUrlParams();
|
||||||
@ -66,6 +69,12 @@ export function UserProgressModal(props: ProgressMapProps) {
|
|||||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCustomResource) {
|
||||||
|
resourceJsonUrl = `${
|
||||||
|
import.meta.env.PUBLIC_API_URL
|
||||||
|
}/v1-get-roadmap/${resourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getUserProgress(
|
async function getUserProgress(
|
||||||
userId: string,
|
userId: string,
|
||||||
resourceType: string,
|
resourceType: string,
|
||||||
@ -92,6 +101,12 @@ export function UserProgressModal(props: ProgressMapProps) {
|
|||||||
throw error || new Error('Something went wrong. Please try again!');
|
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, {
|
return await wireframeJSONToSVG(roadmapJson, {
|
||||||
fontURL: '/fonts/balsamiq.woff2',
|
fontURL: '/fonts/balsamiq.woff2',
|
||||||
});
|
});
|
||||||
@ -165,6 +180,14 @@ export function UserProgressModal(props: ProgressMapProps) {
|
|||||||
el.removeAttribute('data-group-id');
|
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);
|
setResourceSvg(svg);
|
||||||
setProgressResponse(user);
|
setProgressResponse(user);
|
||||||
})
|
})
|
||||||
|
File diff suppressed because it is too large
Load Diff
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@ -4,6 +4,7 @@ interface ImportMetaEnv {
|
|||||||
GITHUB_SHA: string;
|
GITHUB_SHA: string;
|
||||||
PUBLIC_API_URL: string;
|
PUBLIC_API_URL: string;
|
||||||
PUBLIC_AVATAR_BASE_URL: string;
|
PUBLIC_AVATAR_BASE_URL: string;
|
||||||
|
PUBLIC_EDITOR_APP_URL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
@ -5,26 +5,35 @@ type CallbackType = (data: {
|
|||||||
resourceType: ResourceType;
|
resourceType: ResourceType;
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
topicId: string;
|
topicId: string;
|
||||||
|
isCustomResource: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
export function useLoadTopic(callback: CallbackType) {
|
export function useLoadTopic(callback: CallbackType) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleTopicClick(e: any) {
|
function handleTopicClick(e: any) {
|
||||||
const { resourceType, resourceId, topicId } = e.detail;
|
const {
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
topicId,
|
||||||
|
isCustomResource = false,
|
||||||
|
} = e.detail;
|
||||||
|
|
||||||
callback({
|
callback({
|
||||||
resourceType,
|
resourceType,
|
||||||
resourceId,
|
resourceId,
|
||||||
topicId,
|
topicId,
|
||||||
|
isCustomResource,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener(`roadmap.topic.click`, handleTopicClick);
|
window.addEventListener(`roadmap.topic.click`, handleTopicClick);
|
||||||
window.addEventListener(`best-practice.topic.click`, handleTopicClick);
|
window.addEventListener(`best-practice.topic.click`, handleTopicClick);
|
||||||
|
window.addEventListener(`roadmap.node.click`, handleTopicClick);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(`roadmap.topic.click`, handleTopicClick);
|
window.removeEventListener(`roadmap.topic.click`, handleTopicClick);
|
||||||
window.removeEventListener(`best-practice.topic.click`, handleTopicClick);
|
window.removeEventListener(`best-practice.topic.click`, handleTopicClick);
|
||||||
|
window.removeEventListener(`roadmap.node.click`, handleTopicClick);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout, { Props as BaseLayoutProps } from './BaseLayout.astro';
|
import BaseLayout, { type Props as BaseLayoutProps } from './BaseLayout.astro';
|
||||||
|
|
||||||
export interface Props extends BaseLayoutProps {}
|
export interface Props extends BaseLayoutProps {}
|
||||||
|
|
||||||
|
@ -82,7 +82,10 @@ const gaPageIdentifier = Astro.url.pathname
|
|||||||
|
|
||||||
<meta property='og:image:width' content='1200' />
|
<meta property='og:image:width' content='1200' />
|
||||||
<meta property='og:image:height' content='630' />
|
<meta property='og:image:height' content='630' />
|
||||||
<meta property='og:image' content={ogImageUrl || 'https://roadmap.sh/images/og-img.png'} />
|
<meta
|
||||||
|
property='og:image'
|
||||||
|
content={ogImageUrl || 'https://roadmap.sh/images/og-img.png'}
|
||||||
|
/>
|
||||||
<meta property='og:image:alt' content='roadmap.sh' />
|
<meta property='og:image:alt' content='roadmap.sh' />
|
||||||
<meta property='og:site_name' content='roadmap.sh' />
|
<meta property='og:site_name' content='roadmap.sh' />
|
||||||
<meta property='og:title' content={title} />
|
<meta property='og:title' content={title} />
|
||||||
@ -153,10 +156,11 @@ const gaPageIdentifier = Astro.url.pathname
|
|||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<Authenticator />
|
<Authenticator />
|
||||||
<slot name="login-popup">
|
<slot name='login-popup'>
|
||||||
<LoginPopup />
|
<LoginPopup />
|
||||||
</slot>
|
</slot>
|
||||||
<Toaster client:only="react" />
|
|
||||||
|
<Toaster client:only='react' />
|
||||||
<CommandMenu client:idle />
|
<CommandMenu client:idle />
|
||||||
<PageProgress initialMessage={initialLoadingMessage} client:idle />
|
<PageProgress initialMessage={initialLoadingMessage} client:idle />
|
||||||
<PageSponsor
|
<PageSponsor
|
||||||
|
6
src/lib/classname.ts
Normal file
6
src/lib/classname.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
@ -71,7 +71,7 @@ export async function httpCall<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.status === 403) {
|
if (data.status === 403) {
|
||||||
window.location.href = '/account';
|
// window.location.href = '/account'; // @fixme redirect option should be configurable
|
||||||
return { response: undefined, error: data as ErrorType };
|
return { response: undefined, error: data as ErrorType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,3 +21,13 @@ export function isLoggedIn() {
|
|||||||
|
|
||||||
return !!token;
|
return !!token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUser() {
|
||||||
|
const token = Cookies.get(TOKEN_COOKIE_NAME);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeToken(token);
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
import { httpGet, httpPost } from './http';
|
import { httpGet, httpPost } from './http';
|
||||||
import { TOKEN_COOKIE_NAME } from './jwt';
|
import { TOKEN_COOKIE_NAME, getUser } from './jwt';
|
||||||
|
// @ts-ignore
|
||||||
import Element = astroHTML.JSX.Element;
|
import Element = astroHTML.JSX.Element;
|
||||||
|
|
||||||
export type ResourceType = 'roadmap' | 'best-practice';
|
export type ResourceType = 'roadmap' | 'best-practice';
|
||||||
@ -92,8 +93,9 @@ export async function getResourceProgress(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressKey = `${resourceType}-${resourceId}-progress`;
|
const userId = getUser()?.id;
|
||||||
const isFavoriteKey = `${resourceType}-${resourceId}-favorite`;
|
const progressKey = `${resourceType}-${resourceId}-${userId}-progress`;
|
||||||
|
const isFavoriteKey = `${resourceType}-${resourceId}-${userId}-favorite`;
|
||||||
|
|
||||||
const rawIsFavorite = localStorage.getItem(isFavoriteKey);
|
const rawIsFavorite = localStorage.getItem(isFavoriteKey);
|
||||||
const isFavorite = JSON.parse(rawIsFavorite || '0') === 1;
|
const isFavorite = JSON.parse(rawIsFavorite || '0') === 1;
|
||||||
@ -175,8 +177,9 @@ export function setResourceProgress(
|
|||||||
learning: string[],
|
learning: string[],
|
||||||
skipped: string[]
|
skipped: string[]
|
||||||
): void {
|
): void {
|
||||||
|
const userId = getUser()?.id;
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
`${resourceType}-${resourceId}-progress`,
|
`${resourceType}-${resourceId}-${userId}-progress`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
done,
|
done,
|
||||||
learning,
|
learning,
|
||||||
@ -205,19 +208,16 @@ export function topicSelectorAll(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Elements with exact match of the topic id
|
getMatchingElements(
|
||||||
parentElement
|
[
|
||||||
.querySelectorAll(`[data-group-id="${topicId}"]`)
|
`[data-group-id="${topicId}"]`, // Elements with exact match of the topic id
|
||||||
.forEach((element) => {
|
`[data-group-id="check:${topicId}"]`, // Matching "check:XXXX" box of the topic
|
||||||
matchingElements.push(element);
|
`[data-node-id="${topicId}"]`, // Matching custom roadmap nodes
|
||||||
});
|
],
|
||||||
|
parentElement
|
||||||
// Matching "check:XXXX" box of the topic
|
).forEach((element) => {
|
||||||
parentElement
|
matchingElements.push(element);
|
||||||
.querySelectorAll(`[data-group-id="check:${topicId}"]`)
|
});
|
||||||
.forEach((element) => {
|
|
||||||
matchingElements.push(element);
|
|
||||||
});
|
|
||||||
|
|
||||||
return matchingElements;
|
return matchingElements;
|
||||||
}
|
}
|
||||||
@ -253,8 +253,12 @@ export function renderTopicProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clearResourceProgress() {
|
export function clearResourceProgress() {
|
||||||
const clickableElements = document.querySelectorAll('.clickable-group');
|
const matchingElements = getMatchingElements([
|
||||||
for (const clickableElement of clickableElements) {
|
'.clickable-group',
|
||||||
|
'[data-type="topic"]',
|
||||||
|
'[data-type="subtopic"]',
|
||||||
|
]);
|
||||||
|
for (const clickableElement of matchingElements) {
|
||||||
clickableElement.classList.remove('done', 'skipped', 'learning', 'removed');
|
clickableElement.classList.remove('done', 'skipped', 'learning', 'removed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -284,6 +288,19 @@ export async function renderResourceProgress(
|
|||||||
refreshProgressCounters();
|
refreshProgressCounters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMatchingElements(
|
||||||
|
quries: string[],
|
||||||
|
parentElement: Document | SVGElement = document
|
||||||
|
): Element[] {
|
||||||
|
const matchingElements: Element[] = [];
|
||||||
|
quries.forEach((query) => {
|
||||||
|
parentElement.querySelectorAll(query).forEach((element) => {
|
||||||
|
matchingElements.push(element);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return matchingElements;
|
||||||
|
}
|
||||||
|
|
||||||
export function refreshProgressCounters() {
|
export function refreshProgressCounters() {
|
||||||
const progressNumsContainers = document.querySelectorAll(
|
const progressNumsContainers = document.querySelectorAll(
|
||||||
'[data-progress-nums-container]'
|
'[data-progress-nums-container]'
|
||||||
@ -293,7 +310,12 @@ export function refreshProgressCounters() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalClickable = document.querySelectorAll('.clickable-group').length;
|
const totalClickable = getMatchingElements([
|
||||||
|
'.clickable-group',
|
||||||
|
'[data-type="topic"]',
|
||||||
|
'[data-type="subtopic"]',
|
||||||
|
]).length;
|
||||||
|
|
||||||
const externalLinks = document.querySelectorAll(
|
const externalLinks = document.querySelectorAll(
|
||||||
'[data-group-id^="ext_link:"]'
|
'[data-group-id^="ext_link:"]'
|
||||||
).length;
|
).length;
|
||||||
@ -325,14 +347,18 @@ export function refreshProgressCounters() {
|
|||||||
totalRemoved;
|
totalRemoved;
|
||||||
|
|
||||||
const totalDone =
|
const totalDone =
|
||||||
document.querySelectorAll('.clickable-group.done:not([data-group-id^="ext_link:"])').length -
|
getMatchingElements([
|
||||||
totalCheckBoxesDone;
|
'.clickable-group.done:not([data-group-id^="ext_link:"])',
|
||||||
|
'[data-node-id].done', // All data-node-id=*.done elements are custom roadmap nodes
|
||||||
|
]).length - totalCheckBoxesDone;
|
||||||
const totalLearning =
|
const totalLearning =
|
||||||
document.querySelectorAll('.clickable-group.learning').length -
|
getMatchingElements([
|
||||||
totalCheckBoxesLearning;
|
'.clickable-group.learning',
|
||||||
|
'[data-node-id].learning',
|
||||||
|
]).length - totalCheckBoxesLearning;
|
||||||
const totalSkipped =
|
const totalSkipped =
|
||||||
document.querySelectorAll('.clickable-group.skipped').length -
|
getMatchingElements(['.clickable-group.skipped', '[data-node-id].skipped'])
|
||||||
totalCheckBoxesSkipped;
|
.length - totalCheckBoxesSkipped;
|
||||||
|
|
||||||
const doneCountEls = document.querySelectorAll('[data-progress-done]');
|
const doneCountEls = document.querySelectorAll('[data-progress-done]');
|
||||||
if (doneCountEls.length > 0) {
|
if (doneCountEls.length > 0) {
|
||||||
@ -364,9 +390,8 @@ export function refreshProgressCounters() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressPercentage = Math.round(
|
const progressPercentage =
|
||||||
((totalDone + totalSkipped) / totalItems) * 100
|
Math.round(((totalDone + totalSkipped) / totalItems) * 100) || 0;
|
||||||
);
|
|
||||||
const progressPercentageEls = document.querySelectorAll(
|
const progressPercentageEls = document.querySelectorAll(
|
||||||
'[data-progress-percentage]'
|
'[data-progress-percentage]'
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
---
|
---
|
||||||
import RoadmapBanner from '../../components/RoadmapBanner.astro';
|
import RoadmapBanner from '../../components/RoadmapBanner.astro';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { getRoadmapTopicFiles,RoadmapTopicFileType } from '../../lib/roadmap-topic';
|
import {
|
||||||
|
getRoadmapTopicFiles,
|
||||||
|
type RoadmapTopicFileType,
|
||||||
|
} from '../../lib/roadmap-topic';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const topicPathMapping = await getRoadmapTopicFiles();
|
const topicPathMapping = await getRoadmapTopicFiles();
|
||||||
@ -22,9 +25,11 @@ export async function getStaticPaths() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { topicId } = Astro.params;
|
const { topicId } = Astro.params;
|
||||||
const { file, roadmapId, roadmap, heading } = Astro.props as RoadmapTopicFileType;
|
const { file, roadmapId, roadmap, heading } =
|
||||||
|
Astro.props as RoadmapTopicFileType;
|
||||||
|
|
||||||
const gitHubBaseUrl = 'https://github.com/kamranahmedse/developer-roadmap/blob/master/src/data';
|
const gitHubBaseUrl =
|
||||||
|
'https://github.com/kamranahmedse/developer-roadmap/blob/master/src/data';
|
||||||
const gitHubFullUrl = file.file.replace(/^.+\/src\/data/, `${gitHubBaseUrl}/`);
|
const gitHubFullUrl = file.file.replace(/^.+\/src\/data/, `${gitHubBaseUrl}/`);
|
||||||
const gitHubRelativeUrl = file.file.replace(/^.+\/src\/data/, 'src/data');
|
const gitHubRelativeUrl = file.file.replace(/^.+\/src\/data/, 'src/data');
|
||||||
---
|
---
|
||||||
@ -37,14 +42,21 @@ const gitHubRelativeUrl = file.file.replace(/^.+\/src\/data/, 'src/data');
|
|||||||
>
|
>
|
||||||
<RoadmapBanner roadmapId={roadmapId} roadmap={roadmap} />
|
<RoadmapBanner roadmapId={roadmapId} roadmap={roadmap} />
|
||||||
<div class='bg-gray-50'>
|
<div class='bg-gray-50'>
|
||||||
|
<div
|
||||||
<div class='container pb-16 prose prose-p:mt-0 prose-h1:mb-4 prose-h2:mb-3 prose-h2:mt-0'>
|
class='container prose pb-16 prose-h1:mb-4 prose-h2:mb-3 prose-h2:mt-0 prose-p:mt-0'
|
||||||
|
>
|
||||||
<main id='main-content'>
|
<main id='main-content'>
|
||||||
<file.Content />
|
<file.Content />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<p class="border border-yellow-500 p-2 rounded-md text-sm bg-white">
|
<p class='rounded-md border border-yellow-500 bg-white p-2 text-sm'>
|
||||||
Found any mistakes? Help us improve by <a id="gh-file-url" rel="nofollow" target="_blank" data-relative-url={gitHubRelativeUrl} href={gitHubFullUrl}>updating the file here.</a>.
|
Found any mistakes? Help us improve by <a
|
||||||
|
id='gh-file-url'
|
||||||
|
rel='nofollow'
|
||||||
|
target='_blank'
|
||||||
|
data-relative-url={gitHubRelativeUrl}
|
||||||
|
href={gitHubFullUrl}>updating the file here.</a
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -13,7 +13,7 @@ import {
|
|||||||
generateArticleSchema,
|
generateArticleSchema,
|
||||||
generateFAQSchema,
|
generateFAQSchema,
|
||||||
} from '../../lib/jsonld-schema';
|
} from '../../lib/jsonld-schema';
|
||||||
import { RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
|
import { type RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const roadmapIds = await getRoadmapIds();
|
const roadmapIds = await getRoadmapIds();
|
||||||
@ -97,7 +97,7 @@ if (roadmapFAQs.length) {
|
|||||||
description={roadmapData.briefDescription}
|
description={roadmapData.briefDescription}
|
||||||
pageUrl={`https://roadmap.sh/${roadmapId}`}
|
pageUrl={`https://roadmap.sh/${roadmapId}`}
|
||||||
/>
|
/>
|
||||||
<TopicDetail client:idle />
|
<TopicDetail client:idle canSubmitContribution={true} />
|
||||||
|
|
||||||
<FrameRenderer
|
<FrameRenderer
|
||||||
resourceType={'roadmap'}
|
resourceType={'roadmap'}
|
||||||
@ -122,7 +122,7 @@ if (roadmapFAQs.length) {
|
|||||||
<UserProgressModal
|
<UserProgressModal
|
||||||
resourceId={roadmapId}
|
resourceId={roadmapId}
|
||||||
resourceType='roadmap'
|
resourceType='roadmap'
|
||||||
client:only="react"
|
client:only='react'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FAQs faqs={roadmapFAQs} />
|
<FAQs faqs={roadmapFAQs} />
|
||||||
|
15
src/pages/account/roadmaps.astro
Normal file
15
src/pages/account/roadmaps.astro
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
import AccountSidebar from '../../components/AccountSidebar.astro';
|
||||||
|
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||||
|
import { RoadmapListPage } from '../../components/CustomRoadmap/RoadmapListPage';
|
||||||
|
---
|
||||||
|
|
||||||
|
<AccountLayout
|
||||||
|
title='Roadmaps'
|
||||||
|
noIndex={true}
|
||||||
|
initialLoadingMessage='Loading roadmaps'
|
||||||
|
>
|
||||||
|
<AccountSidebar activePageId='roadmaps' activePageTitle='Roadmaps'>
|
||||||
|
<RoadmapListPage client:only='react' />
|
||||||
|
</AccountSidebar>
|
||||||
|
</AccountLayout>
|
@ -9,9 +9,8 @@ import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|||||||
import { UserProgressModal } from '../../../components/UserProgress/UserProgressModal';
|
import { UserProgressModal } from '../../../components/UserProgress/UserProgressModal';
|
||||||
import {
|
import {
|
||||||
type BestPracticeFileType,
|
type BestPracticeFileType,
|
||||||
BestPracticeFrontmatter,
|
type BestPracticeFrontmatter,
|
||||||
getAllBestPractices,
|
getAllBestPractices,
|
||||||
getBestPracticeIds,
|
|
||||||
} from '../../../lib/best-pratice';
|
} from '../../../lib/best-pratice';
|
||||||
import { generateArticleSchema } from '../../../lib/jsonld-schema';
|
import { generateArticleSchema } from '../../../lib/jsonld-schema';
|
||||||
|
|
||||||
@ -90,7 +89,7 @@ if (bestPracticeData.schema) {
|
|||||||
pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`}
|
pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TopicDetail client:idle />
|
<TopicDetail client:idle canSubmitContribution={true} />
|
||||||
|
|
||||||
<FrameRenderer
|
<FrameRenderer
|
||||||
resourceType={'best-practice'}
|
resourceType={'best-practice'}
|
||||||
|
@ -35,6 +35,7 @@ const videos = await getAllVideos();
|
|||||||
isNew: roadmapItem.frontmatter.isNew,
|
isNew: roadmapItem.frontmatter.isNew,
|
||||||
isUpcoming: roadmapItem.frontmatter.isUpcoming,
|
isUpcoming: roadmapItem.frontmatter.isUpcoming,
|
||||||
}))}
|
}))}
|
||||||
|
showCreateRoadmap={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FeaturedItems
|
<FeaturedItems
|
||||||
@ -48,6 +49,7 @@ const videos = await getAllVideos();
|
|||||||
isNew: roadmapItem.frontmatter.isNew,
|
isNew: roadmapItem.frontmatter.isNew,
|
||||||
isUpcoming: roadmapItem.frontmatter.isUpcoming,
|
isUpcoming: roadmapItem.frontmatter.isUpcoming,
|
||||||
}))}
|
}))}
|
||||||
|
showCreateRoadmap={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FeaturedItems
|
<FeaturedItems
|
||||||
|
22
src/pages/r/index.astro
Normal file
22
src/pages/r/index.astro
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { CustomRoadmap } from '../../components/CustomRoadmap/CustomRoadmap';
|
||||||
|
import { SkeletonRoadmapHeader } from '../../components/CustomRoadmap/SkeletonRoadmapHeader';
|
||||||
|
import Loader from '../../components/Loader.astro';
|
||||||
|
import ProgressHelpPopup from '../../components/ProgressHelpPopup.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title='Roadmaps'>
|
||||||
|
<ProgressHelpPopup />
|
||||||
|
<div>
|
||||||
|
<div class='flex min-h-[550px] flex-col'>
|
||||||
|
<div data-roadmap-loader class='flex w-full grow flex-col'>
|
||||||
|
<SkeletonRoadmapHeader />
|
||||||
|
<div class='flex grow items-center justify-center'>
|
||||||
|
<Loader />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CustomRoadmap client:only='react' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { TeamSidebar } from '../../components/TeamSidebar';
|
import { TeamSidebar } from '../../components/TeamSidebar';
|
||||||
import { TeamRoadmaps } from '../../components/TeamRoadmaps';
|
import { TeamRoadmaps } from '../../components/TeamRoadmapsList/TeamRoadmaps';
|
||||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
|
12
src/stores/roadmap.ts
Normal file
12
src/stores/roadmap.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { atom, computed } from 'nanostores';
|
||||||
|
import { type RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||||
|
|
||||||
|
export const currentRoadmap = atom<RoadmapDocument | undefined>(undefined);
|
||||||
|
export const isCurrentRoadmapPersonal = computed(
|
||||||
|
currentRoadmap,
|
||||||
|
(roadmap) => !roadmap?.teamId
|
||||||
|
);
|
||||||
|
export const canManageCurrentRoadmap = computed(
|
||||||
|
currentRoadmap,
|
||||||
|
(roadmap) => roadmap?.canManage
|
||||||
|
);
|
@ -25,9 +25,19 @@ module.exports = {
|
|||||||
transform: 'translateY(0)',
|
transform: 'translateY(0)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'fade-in': {
|
||||||
|
'0%': {
|
||||||
|
opacity: '0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'fade-slide-up': 'fade-slide-up 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
'fade-slide-up':
|
||||||
|
'fade-slide-up 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
||||||
|
'fade-in': 'fade-in 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user