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>
roadmap/copy
Arik Chakma 1 year ago committed by GitHub
parent d45c8f9cb2
commit 8310671123
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .env.example
  2. 1
      .github/workflows/deploy.yml
  3. 5
      .gitignore
  4. 5
      package.json
  5. 431
      pnpm-lock.yaml
  6. 14
      renderer/index.tsx
  7. 5
      renderer/renderer.ts
  8. 31
      scripts/generate-renderer.sh
  9. 25
      src/components/AccountSidebar.astro
  10. 35
      src/components/Activity/ActivityPage.tsx
  11. 17
      src/components/Activity/ResourceProgress.tsx
  12. 2
      src/components/AddTeamRoadmap.tsx
  13. 1
      src/components/Authenticator/authenticator.ts
  14. 199
      src/components/CreateTeam/RoadmapSelector.tsx
  15. 4
      src/components/CreateTeam/SelectRoadmapModal.tsx
  16. 18
      src/components/CreateTeam/Step0.tsx
  17. 4
      src/components/CreateTeam/Step1.tsx
  18. 13
      src/components/CreateTeam/Step2.tsx
  19. 3
      src/components/CreateTeam/Step3.tsx
  20. 16
      src/components/CreateTeam/UpdateTeamResourceModal.tsx
  21. 53
      src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapButton.tsx
  22. 275
      src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx
  23. 121
      src/components/CustomRoadmap/CustomRoadmap.tsx
  24. 12
      src/components/CustomRoadmap/EmptyRoadmap.tsx
  25. 94
      src/components/CustomRoadmap/PersonalRoadmapActionDropdown.tsx
  26. 238
      src/components/CustomRoadmap/PersonalRoadmapList.tsx
  27. 111
      src/components/CustomRoadmap/ResourceProgressStats.tsx
  28. 52
      src/components/CustomRoadmap/RestrictedPage.tsx
  29. 85
      src/components/CustomRoadmap/RoadmapActionButton.tsx
  30. 142
      src/components/CustomRoadmap/RoadmapHeader.tsx
  31. 48
      src/components/CustomRoadmap/RoadmapHint.tsx
  32. 134
      src/components/CustomRoadmap/RoadmapListPage.tsx
  33. 53
      src/components/CustomRoadmap/RoadmapRenderer.css
  34. 177
      src/components/CustomRoadmap/RoadmapRenderer.tsx
  35. 162
      src/components/CustomRoadmap/ShareRoadmapModal.tsx
  36. 118
      src/components/CustomRoadmap/SharedRoadmapList.tsx
  37. 28
      src/components/CustomRoadmap/SkeletonRoadmapHeader.tsx
  38. 24
      src/components/FeaturedItems/FeaturedItems.astro
  39. 14
      src/components/Friends/FriendProgressItem.tsx
  40. 6
      src/components/Friends/FriendsPage.tsx
  41. 7
      src/components/Friends/SidebarFriendsCounter.tsx
  42. 27
      src/components/HeroSection/FavoriteRoadmaps.tsx
  43. 48
      src/components/HeroSection/HeroRoadmaps.tsx
  44. 46
      src/components/Modal.tsx
  45. 4
      src/components/Navigation/AccountDropdown.astro
  46. 52
      src/components/Navigation/AccountDropdown.tsx
  47. 49
      src/components/Navigation/AccountDropdownList.tsx
  48. 110
      src/components/Navigation/DropdownTeamList.tsx
  49. 12
      src/components/Navigation/Navigation.astro
  50. 64
      src/components/ShareOptions/CopyRoadmapLink.tsx
  51. 160
      src/components/ShareOptions/ShareFriendList.tsx
  52. 311
      src/components/ShareOptions/ShareOptionsModal.tsx
  53. 129
      src/components/ShareOptions/ShareOptionsTab.tsx
  54. 164
      src/components/ShareOptions/ShareTeamMemberList.tsx
  55. 114
      src/components/ShareOptions/TransferToTeamList.tsx
  56. 46
      src/components/ShareOptions/UserItem.tsx
  57. 9
      src/components/Stepper.tsx
  58. 2
      src/components/TeamDropdown/TeamDropdown.tsx
  59. 16
      src/components/TeamMembers/MemberActionDropdown.tsx
  60. 18
      src/components/TeamMembers/TeamMemberItem.tsx
  61. 8
      src/components/TeamProgress/GroupRoadmapItem.tsx
  62. 14
      src/components/TeamProgress/MemberProgressItem.tsx
  63. 91
      src/components/TeamProgress/MemberProgressModal.tsx
  64. 15
      src/components/TeamProgress/TeamProgressPage.tsx
  65. 3
      src/components/TeamRoadmap/CustomTeamRoadmap.tsx
  66. 3
      src/components/TeamRoadmap/DefaultTeamRoadmap.tsx
  67. 346
      src/components/TeamRoadmaps.tsx
  68. 39
      src/components/TeamRoadmaps/PickRoadmapOptionModal.tsx
  69. 93
      src/components/TeamRoadmapsList/RoadmapActionDropdown.tsx
  70. 636
      src/components/TeamRoadmapsList/TeamRoadmaps.tsx
  71. 28
      src/components/TeamsList.tsx
  72. 2
      src/components/Toast.tsx
  73. 61
      src/components/Tooltip.tsx
  74. 133
      src/components/TopicDetail/TopicDetail.tsx
  75. 19
      src/components/UserProgress/ProgressShareButton.tsx
  76. 23
      src/components/UserProgress/UserProgressModal.tsx
  77. 510
      src/data/roadmaps/ai-data-scientist/ai-data-scientist.json
  78. 1
      src/env.d.ts
  79. 11
      src/hooks/use-load-topic.ts
  80. 2
      src/layouts/AccountLayout.astro
  81. 10
      src/layouts/BaseLayout.astro
  82. 6
      src/lib/classname.ts
  83. 2
      src/lib/http.ts
  84. 10
      src/lib/jwt.ts
  85. 77
      src/lib/resource-progress.ts
  86. 26
      src/pages/[roadmapId]/[...topicId].astro
  87. 6
      src/pages/[roadmapId]/index.astro
  88. 15
      src/pages/account/roadmaps.astro
  89. 5
      src/pages/best-practices/[bestPracticeId]/index.astro
  90. 2
      src/pages/index.astro
  91. 22
      src/pages/r/index.astro
  92. 2
      src/pages/team/roadmaps.astro
  93. 12
      src/stores/roadmap.ts
  94. 12
      tailwind.config.cjs

@ -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=

@ -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

@ -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": {

@ -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

@ -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>
);
}

@ -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");
}

@ -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' : ''
}`} }`}
> >
{sidebarLink.icon.component ? (
<sidebarLink.icon.component
className={`${sidebarLink.icon.classes} mr-2`}
/>
) : (
<AstroIcon <AstroIcon
icon={sidebarLink.icon.glyph} icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`} 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'>
{sidebarLink.icon.component ? (
<sidebarLink.icon.component
className={`${sidebarLink.icon.classes} mr-2`}
/>
) : (
<AstroIcon <AstroIcon
icon={sidebarLink.icon.glyph} icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`} 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;
@ -111,6 +107,7 @@ 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,55 +172,151 @@ 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 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> </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 ( return (
<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="relative flex flex-col items-start overflow-hidden rounded-md border border-gray-300"
<span className="mb-0.5 block text-base font-medium leading-none text-black"> 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} {roadmapTitle}
</span> </span>
{removedTopics.length > 0 ? ( {removedTopics.length > 0 || (topics && topics > 0) ? (
<span className={'text-xs leading-none text-gray-900'}> <span className={'text-xs leading-none text-gray-400'}>
{isCustomResource ? (
<>
Custom &middot; {topics} topic
{topics && topics > 1 ? 's' : ''}
</>
) : (
<>
{removedTopics.length} topic {removedTopics.length} topic
{removedTopics.length > 1 ? 's' : ''} removed {removedTopics.length > 1 ? 's' : ''} removed
</>
)}
</span> </span>
) : ( ) : (
<span className="text-xs italic leading-none text-gray-400/60"> <span className="text-xs italic leading-none text-gray-400/60">
No changes made .. {isCustomResource
? 'Placeholder roadmap.'
: 'No changes made ..'}
</span> </span>
)} )}
</div> </div>
{removingRoadmapId === resourceId && (
<div
className={
'flex w-full items-center justify-end p-3 text-sm'
}
>
<span className="text-xs text-gray-500">
Are you sure?{' '}
<button
onClick={() => onRemove(resourceId)}
className="mx-0.5 text-red-500 underline underline-offset-1"
>
Yes
</button>{' '}
<button
onClick={() => setRemovingRoadmapId('')}
className="text-red-500 underline underline-offset-1"
>
No
</button>
</span>
</div>
)}
{(!removingRoadmapId || removingRoadmapId !== resourceId) && (
<div className={'flex w-full justify-between p-3'}> <div className={'flex w-full justify-between p-3'}>
<button <button
type="button" type="button"
className={ className={
'text-xs text-gray-500 underline hover:text-black focus:outline-none' 'text-xs text-gray-500 underline hover:text-black focus:outline-none'
} }
onClick={() => setChangingRoadmapId(resourceId)} onClick={() => {
if (isCustomResource) {
window.open(
`${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${resourceId}`,
'_blank'
);
return;
}
setChangingRoadmapId(resourceId);
}}
> >
Customize Customize
</button> </button>
@ -203,14 +326,16 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
className={ className={
'text-xs text-red-500 underline hover:text-black' 'text-xs text-red-500 underline hover:text-black'
} }
onClick={() => onRemove(resourceId)} onClick={() => setRemovingRoadmapId(resourceId)}
> >
Remove Remove
</button> </button>
</div> </div>
)}
</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>
);
}

@ -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}
/>
</>
);
}

@ -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>
);
}

@ -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">&middot;</span>
<Shapes size={16} className="mr-1 inline-block h-4 w-4" />
{roadmap.topics} topic
</span>
</div>
<div className="mr-1 flex items-center justify-start sm:justify-end">
<PersonalRoadmapActionDropdown
onUpdateSharing={() => {
setSelectedRoadmap(roadmap);
}}
onCustomize={() => {
window.open(editorLink, '_blank');
}}
onDelete={() => {
if (confirm('Are you sure you want to remove this roadmap?')) {
onRemove(roadmap._id!).finally(() => {});
}
}}
/>
<a
href={`/r?id=${roadmap._id}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
}
target={'_blank'}
>
<ExternalLink className="inline-block h-4 w-4" />
Visit
</a>
</div>
</li>
);
}
type VisibilityLabelProps = {
visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[];
};
const visibilityDetails: Record<
AllowedRoadmapVisibility,
{
icon: LucideIcon;
label: string;
}
> = {
public: {
icon: Globe,
label: 'Public',
},
me: {
icon: LockIcon,
label: 'Only me',
},
team: {
icon: Users,
label: 'Team Member(s)',
},
friends: {
icon: Users,
label: 'Friend(s)',
},
} as const;
function VisibilityBadge(props: VisibilityLabelProps) {
const { visibility, sharedFriendIds = [] } = props;
const { label, icon: Icon } = visibilityDetails[visibility];
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`}
>
<Icon className="inline-block h-3 w-3" />
<div className="flex items-center">
{visibility === 'friends' && sharedFriendIds?.length > 0 && (
<span className="mr-1">{sharedFriendIds.length}</span>
)}
{label}
</div>
</span>
);
}

@ -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">&middot;</span>
<span>
<span data-progress-learning="">0</span> in progress
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span data-progress-skipped="">0</span> skipped
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span data-progress-total="">0</span> Total
</span>
</span>
<span className="md:hidden">
<span data-progress-done="">0</span> of{' '}
<span data-progress-total="">0</span> Done
</span>
</p>
<div
className="flex items-center gap-3 opacity-0 transition-opacity duration-300"
data-progress-nums=""
>
<button
data-popup="progress-help"
className="flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black"
data-progress-nums=""
>
<HelpCircle className="h-3.5 w-3.5 stroke-[2.5px]" />
Track Progress
</button>
</div>
</div>
<div
data-progress-nums-container=""
className="striped-loader relative -mb-2 flex items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden"
>
<span
data-progress-nums=""
className="text-gray-500 opacity-0 transition-opacity duration-300"
>
<span data-progress-done="">0</span> of{' '}
<span data-progress-total="">0</span> Done
</span>
</div>
</>
);
}

@ -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"
>
&larr; Go back to home
</a>
</div>
);
}

@ -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>
);
}

@ -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"
>
&larr;<span className="hidden sm:inline">&nbsp;All Roadmaps</span>
</a>
<button
data-guest-required
data-popup="login-popup"
className="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
aria-label="Subscribe for Updates"
>
<span className="ml-2">Subscribe</span>
</button>
</div>
{$canManageCurrentRoadmap && (
<div className="flex items-center gap-2">
{isSharing && $currentRoadmap && (
<ShareOptionsModal
visibility={$currentRoadmap?.visibility}
teamId={$currentRoadmap?.teamId}
roadmapId={$currentRoadmap?._id!}
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
sharedTeamMemberIds={
$currentRoadmap?.sharedTeamMemberIds || []
}
onClose={() => setIsSharing(false)}
onShareSettingsUpdate={(settings) => {
currentRoadmap.set({
...$currentRoadmap,
...settings,
});
}}
/>
)}
<RoadmapActionButton
onDelete={() => {
const confirmation = window.confirm(
'Are you sure you want to delete this roadmap?'
);
if (!confirmation) {
return;
}
deleteResource().finally(() => null);
}}
onCustomize={() => {
const editorLink = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${$currentRoadmap?._id}`;
window.open(editorLink, '_blank');
}}
onUpdateSharing={() => {
setIsSharing(true);
}}
/>
</div>
)}
</div>
<RoadmapHint
roadmapTitle={title!}
hasTNSBanner={false}
roadmapId={roadmapId!}
/>
</div>
</div>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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;
}

@ -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>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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,9 +82,8 @@ 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) {
@ -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,7 +120,6 @@ export function HeroRoadmaps(props: ProgressListProps) {
))} ))}
</div> </div>
{showCustomRoadmaps && (
<div className="mt-5"> <div className="mt-5">
{ {
<HeroTitle <HeroTitle
@ -130,26 +131,39 @@ export function HeroRoadmaps(props: ProgressListProps) {
{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
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
onClick={() => setIsCreatingRoadmap(true)}
>
Create one! Create one!
</button> </button>
</p> </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) => {
return (
<HeroRoadmap <HeroRoadmap
resourceId={'343434'} key={customRoadmap.resourceId}
resourceId={customRoadmap.resourceId}
resourceType={'roadmap'} resourceType={'roadmap'}
resourceTitle={'Frontend Roadmap Revised'} resourceTitle={customRoadmap.resourceTitle}
percentageDone={50} percentageDone={
url={`/r?${'34343434'}`} ((customRoadmap.skipped + customRoadmap.done) /
customRoadmap.total) *
100
}
url={`/r?id=${customRoadmap.resourceId}`}
allowFavorite={false} allowFavorite={false}
/> />
))} );
</div> })}
<CreateRoadmapButton />
</div> </div>
)} )}
</div> </div>
</div>
); );
} }

@ -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]'

@ -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&nbsp;<span className="text-gray-300">/</span>&nbsp;Teams
</span>
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
</button>
{showDropdown && (
<div
ref={dropdownRef}
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
>
{isTeamsOpen ? (
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
) : (
<AccountDropdownList setIsTeamsOpen={setIsTeamsOpen} />
)}
</div>
)}
</div>
);
}

@ -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>
);
}

@ -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

@ -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>
);
}

@ -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>
)}
</>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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>
)}
</>
);
}

@ -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>
)}
</>
);
}

@ -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">

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

@ -11,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, {
...(isCustomResource && {
credentials: 'include',
}),
});
const json = await res.json(); const json = await res.json();
const svg = await wireframeJSONToSVG(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', fontURL: '/fonts/balsamiq.woff2',
}); });
}
containerEl.current?.replaceChildren(svg); containerEl.current?.replaceChildren(svg);
} }
@ -186,10 +215,29 @@ export function MemberProgressModal(props: ProgressMapProps) {
return; return;
} }
let topicId = '';
if (isCustomResource) {
const { nodeId, nodeType } = getNodeDetails(e.target as SVGElement) || {};
if (
!nodeId ||
!nodeType ||
!allowedClickableNodeTypes.includes(nodeType)
) {
return;
}
if (nodeType === 'button') {
return;
}
topicId = nodeId;
} else {
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : ''; const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) { if (!groupId) {
return; return;
} }
topicId = groupId.replace(/^\d+-/, '');
}
if (targetGroup.classList.contains('removed')) { if (targetGroup.classList.contains('removed')) {
e.preventDefault(); e.preventDefault();
@ -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,25 +255,42 @@ export function MemberProgressModal(props: ProgressMapProps) {
if (!targetGroup) { if (!targetGroup) {
return; return;
} }
let topicId = '';
if (isCustomResource) {
const { nodeId, nodeType } = getNodeDetails(e.target as SVGElement) || {};
if (
!nodeId ||
!nodeType ||
!allowedClickableNodeTypes.includes(nodeType)
) {
return;
}
if (nodeType === 'button') {
return;
}
topicId = nodeId;
} else {
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : ''; const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) { if (!groupId) {
return; return;
} }
topicId = groupId.replace(/^\d+-/, '');
}
if (targetGroup.classList.contains('removed')) { if (targetGroup.classList.contains('removed')) {
return; return;
} }
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,
}); });
}} }}
/> />

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

@ -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>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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">&middot;</span>
<Shapes size={16} className="mr-1 inline-block h-4 w-4" />
{resourceConfig.topics} topic
</span>
</div>
<div className="mr-1 flex items-center justify-start sm:justify-end">
{canManageCurrentTeam && (
<RoadmapActionDropdown
onUpdateSharing={() => {
setSelectedResource(resourceConfig);
}}
onCustomize={() => {
window.open(editorLink, '_blank');
}}
onDelete={() => {
if (
confirm(
'Are you sure you want to remove this roadmap?'
)
) {
onRemove(resourceConfig.resourceId).finally(
() => {}
);
}
}}
/>
)}
<a
href={`/r?id=${resourceConfig.resourceId}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
}
target={'_blank'}
>
<ExternalLink className="inline-block h-4 w-4" />
Visit
</a>
</div>
</div>
);
})}
</div>
</div>
)}
{defaultRoadmaps.length > 0 && (
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400">
<span className="flex">Default Roadmaps</span>
<span className="normal-case">
Total {defaultRoadmaps.length} roadmap(s)
</span>
</h3>
</div>
<div className="flex flex-col divide-y rounded-md border">
{defaultRoadmaps.map((resourceConfig: TeamResourceConfig[0]) => {
return (
<div
className="grid grid-cols-1 p-3 sm:grid-cols-[auto_110px]"
key={resourceConfig.resourceId}
>
<div className="mb-3 grid grid-cols-1 sm:mb-0">
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
{resourceConfig.title}
</p>
<span className="flex items-center text-xs leading-none text-gray-400">
{resourceConfig?.removed?.length > 0 && (
<>
<PackageMinus
size={16}
className="mr-1 inline-block h-4 w-4"
/>
{resourceConfig.removed.length} topics removed
</>
)}
{!resourceConfig?.removed?.length && (
<>
<Package
size={16}
className="mr-1 inline-block h-4 w-4"
/>
No changes made
</>
)}
</span>
</div>
<div className="mr-1 flex items-center justify-start sm:justify-end">
{canManageCurrentTeam && (
<RoadmapActionDropdown
onCustomize={() => {
setChangingRoadmapId(resourceConfig.resourceId);
}}
onDelete={() => {
if (
confirm(
'Are you sure you want to remove this roadmap?'
)
) {
onRemove(resourceConfig.resourceId).finally(
() => {}
);
}
}}
/>
)}
<a
href={`/${resourceConfig.resourceId}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
}
target={'_blank'}
>
<ExternalLink className="inline-block h-4 w-4" />
Visit
</a>
</div>
</div>
);
})}
</div>
</div>
)}
{canManageCurrentTeam && (
<div className="mt-5">
<button
className="block w-full rounded-md border border-dashed border-gray-300 py-2 text-sm transition-colors hover:border-gray-600 hover:bg-gray-50 focus:outline-0"
onClick={() => setIsPickingOptions(true)}
>
+ Add new Roadmap
</button>
</div>
)}
</div>
);
}
type VisibilityLabelProps = {
visibility: AllowedRoadmapVisibility;
sharedTeamMemberIds?: string[];
sharedFriendIds?: string[];
};
const visibilityDetails: Record<
AllowedRoadmapVisibility,
{
icon: LucideIcon;
label: string;
}
> = {
public: {
icon: Globe,
label: 'Public',
},
me: {
icon: LockIcon,
label: 'Only me',
},
team: {
icon: Users,
label: 'Team Member(s)',
},
friends: {
icon: Users,
label: 'Friend(s)',
},
} as const;
export function VisibilityBadge(props: VisibilityLabelProps) {
const { visibility, sharedTeamMemberIds = [], sharedFriendIds = [] } = props;
const { label, icon: Icon } = visibilityDetails[visibility];
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`}
>
<Icon className="inline-block h-3 w-3" />
<div className="flex items-center">
{visibility === 'team' && sharedTeamMemberIds?.length > 0 && (
<span className="mr-1">{sharedTeamMemberIds.length}</span>
)}
{visibility === 'friends' && sharedFriendIds?.length > 0 && (
<span className="mr-1">{sharedFriendIds.length}</span>
)}
{label}
</div>
</span>
);
}

@ -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,11 +59,19 @@ export function TeamsList() {
<span>&rarr;</span> <span>&rarr;</span>
</a> </a>
</li> </li>
{teamList.map((team) => ( {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}> <li key={team._id}>
<a <a
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50" className="flex w-full cursor-pointer items-center justify-between gap-2 rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50"
href={`/team/progress?t=${team._id}`} href={pageLink}
> >
<span className="flex flex-grow items-center gap-2"> <span className="flex flex-grow items-center gap-2">
<img <img
@ -87,7 +90,8 @@ export function TeamsList() {
<span>&rarr;</span> <span>&rarr;</span>
</a> </a>
</li> </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`}

@ -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,
{}, {},
{ {
...(!isCustomResource && {
headers: { headers: {
Accept: 'text/html', 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 = '';
if (!isCustomResource) {
// It's full HTML with page body, head etc. // It's full HTML with page body, head etc.
// We only need the inner HTML of the #main-content // We only need the inner HTML of the #main-content
const node = new DOMParser().parseFromString(response, 'text/html'); const node = new DOMParser().parseFromString(
const topicHtml = node?.getElementById('main-content')?.outerHTML || ''; 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,13 +249,52 @@ export function TopicDetail() {
</div> </div>
{/* Topic Content */} {/* Topic Content */}
{hasContent ? (
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
{topicTitle && <h1>{topicTitle}</h1>}
<div <div
id="topic-content" id="topic-content"
className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5"
dangerouslySetInnerHTML={{ __html: topicHtml }} dangerouslySetInnerHTML={{ __html: topicHtml }}
></div> />
</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 */}
{canSubmitContribution && (
<div className="mt-8 flex-1 border-t"> <div className="mt-8 flex-1 border-t">
<p className="mb-2 mt-2 text-sm leading-relaxed text-gray-400"> <p className="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
Help others learn by submitting links to learn more about this Help others learn by submitting links to learn more about this
@ -227,6 +318,28 @@ export function TopicDetail() {
: 'Submit a Link'} : 'Submit a Link'}
</button> </button>
</div> </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> </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

@ -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

@ -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,17 +208,14 @@ export function topicSelectorAll(
} }
}); });
// Elements with exact match of the topic id getMatchingElements(
[
`[data-group-id="${topicId}"]`, // Elements with exact match of the topic id
`[data-group-id="check:${topicId}"]`, // Matching "check:XXXX" box of the topic
`[data-node-id="${topicId}"]`, // Matching custom roadmap nodes
],
parentElement parentElement
.querySelectorAll(`[data-group-id="${topicId}"]`) ).forEach((element) => {
.forEach((element) => {
matchingElements.push(element);
});
// Matching "check:XXXX" box of the topic
parentElement
.querySelectorAll(`[data-group-id="check:${topicId}"]`)
.forEach((element) => {
matchingElements.push(element); matchingElements.push(element);
}); });
@ -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} />

@ -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

@ -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';
--- ---

@ -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…
Cancel
Save