Compare commits

...

124 Commits

Author SHA1 Message Date
Kamran Ahmed
afb4e0e587 Merge branch 'feat/roadmap' of github.com:kamranahmedse/developer-roadmap into feat/roadmap 2023-09-30 11:42:10 +01:00
Kamran Ahmed
94f683a688 Add action button on roadmap detail page 2023-09-30 11:42:00 +01:00
Arik Chakma
e22c794243 Change Secret name 2023-09-30 15:56:36 +06:00
Kamran Ahmed
2a36c5cc45 Rearrange sections 2023-09-30 10:48:38 +01:00
Arik Chakma
656cc293c1 Update teams type 2023-09-30 15:09:40 +06:00
Arik Chakma
b0f2ad121a Add Roadmap Secret 2023-09-30 05:16:49 +06:00
Arik Chakma
8beb235363 Update Personal roadmaps UI 2023-09-30 02:14:53 +06:00
Kamran Ahmed
552a4d5bea Fix team issue 2023-09-29 20:03:16 +01:00
Arik Chakma
817636d972 Update Minor UI 2023-09-29 16:59:53 +06:00
Kamran Ahmed
995fe91505 Update UI 2023-09-29 11:28:13 +01:00
Kamran Ahmed
47163cde95 Update 2023-09-28 23:14:18 +01:00
Arik Chakma
6ba922c406 wip: add share settings 2023-09-29 02:56:12 +06:00
Kamran Ahmed
0f5ea52789 Add generate renderer 2023-09-28 20:07:04 +01:00
Kamran Ahmed
445f715c49 Add generate renderer script 2023-09-28 20:05:16 +01:00
Kamran Ahmed
544c33a7d8 Add renderer script 2023-09-28 19:58:45 +01:00
Kamran Ahmed
9ed92ff1c1 Add dummy renderer 2023-09-28 19:58:32 +01:00
Kamran Ahmed
7557ce9774 Merge branch 'feat/roadmap' of github.com:kamranahmedse/developer-roadmap into feat/roadmap 2023-09-28 19:40:42 +01:00
Kamran Ahmed
8f86b5336d Update roadmap options modal 2023-09-28 19:40:33 +01:00
Arik Chakma
8df76dab37 fix: remove visibility change 2023-09-29 00:13:56 +06:00
Kamran Ahmed
ae31eb191e Team roadmaps listing 2023-09-28 19:03:47 +01:00
Kamran Ahmed
920e30d501 Update team settings 2023-09-28 18:19:57 +01:00
Kamran Ahmed
75d20f189e Team roadmaps listing 2023-09-28 18:05:03 +01:00
Kamran Ahmed
6ee70cf1b0 Roadmap action dropdown with responsiveness 2023-09-28 16:33:58 +01:00
Kamran Ahmed
b9313751f9 Flicker fix in team dropdown 2023-09-27 11:51:48 +01:00
Arik Chakma
00854190a4 Fix minor UI 2023-09-27 13:09:52 +06:00
Kamran Ahmed
6c7f640498 Minor UI fixes 2023-09-27 01:40:15 +01:00
Kamran Ahmed
8f66f653a8 Update team dropdown 2023-09-27 01:34:32 +01:00
Arik Chakma
63123a3735 Change Icon color 2023-09-27 01:54:17 +06:00
Arik Chakma
3005ad9c28 Fix Team name overflow 2023-09-27 01:18:25 +06:00
Arik Chakma
03473193ba Refactor Account dropdown 2023-09-27 01:16:35 +06:00
Kamran Ahmed
e874e0ce84 Merge branch 'feat/roadmap' of github.com:kamranahmedse/developer-roadmap into feat/roadmap 2023-09-26 18:53:44 +01:00
Kamran Ahmed
11d65c649b Updates 2023-09-26 18:53:36 +01:00
Arik Chakma
5fa51bf5ca Refactor Account dropdown 2023-09-26 23:50:35 +06:00
Kamran Ahmed
3d97ee0214 Refactor team roadmaps listing 2023-09-25 03:36:54 +01:00
Kamran Ahmed
ed2341b020 Refactor team roadmaps 2023-09-25 03:21:20 +01:00
Kamran Ahmed
4c111a1a0f Refactor team roadmap loading 2023-09-25 03:14:24 +01:00
Kamran Ahmed
52bc5b7796 Refactor 2023-09-25 02:46:40 +01:00
Kamran Ahmed
db25d4fb61 Refactor team creation roadmap selection 2023-09-25 02:39:29 +01:00
Kamran Ahmed
26f3700f52 Refactor team roadmaps 2023-09-25 02:01:40 +01:00
Kamran Ahmed
42e91ddb9a Refactor team creation 2023-09-25 00:35:40 +01:00
Kamran Ahmed
11fea95acf Refactor roadmap creation 2023-09-24 23:52:55 +01:00
Kamran Ahmed
f3b615ef58 Refactor roadmap creation 2023-09-24 23:52:55 +01:00
Arik Chakma
af2dff47d8 fix: link groups 2023-09-24 18:38:29 +06:00
Arik Chakma
b71c9f1175 Add Modal close button 2023-09-24 14:50:10 +06:00
Arik Chakma
211089e602 Implement Error in topic details 2023-09-24 09:21:10 +06:00
Kamran Ahmed
1e0e40c989 Merge branch 'feat/roadmap' of github.com:kamranahmedse/developer-roadmap into feat/roadmap 2023-09-23 19:49:29 +01:00
Arik Chakma
b41a28fbeb chore: show progress bar 2023-09-24 00:08:53 +06:00
Arik Chakma
e27560c0db chore: empty screen and topic title 2023-09-23 23:38:05 +06:00
Kamran Ahmed
615452f455 Merge branch 'master' of github.com:kamranahmedse/developer-roadmap into feat/roadmap 2023-09-23 15:44:45 +01:00
Akshay Jagiasi
7bde0b3f44 Add EVM link (#3727) 2023-09-22 20:46:09 +01:00
Lane Wagner
4b6dcb3a37 Add golang course (#3730) 2023-09-22 20:45:29 +01:00
Alyxson Marques
c50200bfe7 Added links to exception handling javascript roadmap (#3775)
Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-22 20:40:06 +01:00
Akshay Jagiasi
5ffb9fad9f Add solana whitepaper (#3781)
* Solana whitepaper added

* Update src/data/roadmaps/blockchain/content/101-blockchain-general-knowledge/109-blockchains/100-solana.md

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-22 20:39:06 +01:00
The New Stack
dd7d312aa1 Add TypeScript resources (#3789)
Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-22 20:34:58 +01:00
The New Stack
81447f6b43 Add TypeScript vs JavaScript (#3790) 2023-09-22 20:33:15 +01:00
Valentino Traverso
fe711f498d Fix typos (#3794) 2023-09-22 20:32:50 +01:00
rane gray
c65f12fcb8 Add zustand resource (#3803)
Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-22 20:27:37 +01:00
Tim Jonas Meinerzhagen
cab075bf5b Fix salting typo link (#3820) 2023-09-22 20:24:50 +01:00
Benjamin Norval
685021493c Fix typos and wording (#3902)
Improved grammar and wording choices for the file of `choose-image-format-approprietly.md` in Frontend Performance - Best Practices.
2023-09-22 20:24:29 +01:00
Rafael Bicalho
482cf64bf5 Update CORS Resources (#3908)
* Update 106-cors.md

* Update 102-cors.md

* Update 106-cors.md
2023-09-22 20:23:59 +01:00
keeplz
9051e22476 Add event loop resource (#3921)
add a video for event loop, it's js conf in asia 2018
2023-09-22 20:23:30 +01:00
Selva Muthu Kumaran
1b538b399f Fix backend link (#4484)
backend-roadmap-throttling- missed URL fixed
fixes : #4473
2023-09-22 20:22:40 +01:00
アドヴァイス
05673087c5 Remove dead link (#4489)
In the "Rate limiting" section, Blogs and tutorials on RxJS refers to a 404 page.

The dead link has been fixed with the correct and appropriate link.
2023-09-22 20:21:59 +01:00
Orca
5256df9c07 Fix typos (#4500)
- Fixed a typo
- Fixed the title of the freecodecamp link
2023-09-22 20:19:23 +01:00
Orca
ddf8884501 Update 100-builtin-modules.md (#4501)
- Removed a redundant `events` entry
2023-09-22 20:18:26 +01:00
steph
05492b60ee Update blockchain resources (#4502) 2023-09-22 20:18:04 +01:00
Arik Chakma
084a87ae61 Merge branch 'master' into feat/roadmap 2023-09-18 22:29:24 +06:00
Arik Chakma
7e2c55e65a chore: shared roadmaps 2023-09-14 02:23:38 +06:00
Arik Chakma
ab52371407 chore: shared roadmaps 2023-09-14 00:50:05 +06:00
Arik Chakma
2255ed74c3 chore: custom roadmap visit 2023-09-12 01:53:56 +06:00
Arik Chakma
b444ec92f1 chore: empty roadmap list 2023-09-12 00:47:48 +06:00
Arik Chakma
db45d4bbe4 wip: manage custom roadmap 2023-09-12 00:19:48 +06:00
Arik Chakma
8e68d6754c chore: multi user history 2023-09-10 01:38:25 +06:00
Arik Chakma
d62721c69d chore: form error 2023-09-10 00:34:19 +06:00
Arik Chakma
1075d5c043 chore: create roadmap responsive 2023-09-10 00:07:26 +06:00
Arik Chakma
6705c1a4af chore: team progress 2023-09-09 22:58:51 +06:00
Arik Chakma
e54d886674 chore: empty screen 2023-09-09 03:17:50 +06:00
Arik Chakma
2479283646 chore: public roadmap 2023-09-09 02:12:09 +06:00
Arik Chakma
50da66feaa chore: visibility label 2023-09-09 01:14:34 +06:00
Arik Chakma
1d703501aa chore: roadmap hint 2023-09-09 00:29:32 +06:00
Arik Chakma
3cfc01a8a7 chore: placeholde roadmaps 2023-09-08 23:52:38 +06:00
Arik Chakma
2461effa52 chore: custom roadmap modal 2023-09-08 21:54:01 +06:00
Arik Chakma
46e9f2d615 chore: create team roadmap 2023-09-08 06:06:37 +06:00
Arik Chakma
4ce6a4368f chore: team roadmap create modal 2023-09-08 01:51:52 +06:00
Arik Chakma
d3db784846 chore: use team id from params 2023-09-07 20:35:21 +06:00
Arik Chakma
db62e5e654 chore: change share title 2023-09-07 04:51:37 +06:00
Arik Chakma
5c27aeb602 chore: open new tab on create roadmap 2023-09-07 03:53:14 +06:00
Arik Chakma
e07ee98a11 chore: pick roadmap and share 2023-09-07 03:01:42 +06:00
Arik Chakma
210c75ec3c chore: remove user id 2023-09-07 01:32:44 +06:00
Arik Chakma
cd1852d376 Fix create roadmap button 2023-09-06 23:49:24 +06:00
Arik Chakma
fad6a2e6ec fix: skeleton loading width 2023-09-06 18:17:33 +06:00
Arik Chakma
89e28c20b7 Restricted Page 2023-09-06 18:08:01 +06:00
Arik Chakma
38fcd32604 chore: roadmap title 2023-09-06 04:38:53 +06:00
Arik Chakma
3c06620cc0 wip: custom roadmap skeleton 2023-09-06 03:19:25 +06:00
Arik Chakma
52279edb07 chore: friend's roadmap 2023-09-06 01:02:15 +06:00
Arik Chakma
ce76a9eb50 wip: custom roadmap share progress 2023-09-06 00:01:47 +06:00
Arik Chakma
b239b62495 chore: custom roadmap progress in activity 2023-09-05 16:55:41 +06:00
Arik Chakma
21d3466afb wip: change visibility 2023-09-05 06:00:25 +06:00
Arik Chakma
dd1af6a8b0 wip: share roadmap 2023-09-05 04:17:27 +06:00
Arik Chakma
ea91b1ec00 chore: share progress modal 2023-09-05 04:02:57 +06:00
Arik Chakma
ed477b9aaa Roadmap type 2023-09-05 02:17:03 +06:00
Arik Chakma
ca5112fd8b Create Roadmap button 2023-09-05 01:53:46 +06:00
Arik Chakma
f98f413029 Create roadmap home 2023-09-04 23:59:05 +06:00
Arik Chakma
ac5ab2f71b Update links color 2023-09-04 23:43:21 +06:00
Arik Chakma
7d21c39860 wip: content links 2023-09-04 23:31:10 +06:00
Arik Chakma
ec9c44be1a chore: roadmap sidebar icon 2023-09-04 21:55:24 +06:00
Arik Chakma
060ed12c2d fix: create roadmap api 2023-09-04 21:47:54 +06:00
Arik Chakma
b9aaf4e66f wip: users all roadmaps 2023-09-04 21:36:02 +06:00
Arik Chakma
f778f3f2bc wip: show custom roadmaps 2023-09-04 20:29:23 +06:00
Arik Chakma
2cb5051bc6 Replace disabled with canShare 2023-09-04 19:51:22 +06:00
Arik Chakma
70aafdeb19 Fix progress share 2023-09-04 19:49:18 +06:00
Arik Chakma
137aa23bfb wip: content links rendering 2023-09-04 19:30:12 +06:00
Arik Chakma
1dfbf1ecad fix: disabled the share button 2023-09-03 18:35:31 +06:00
Arik Chakma
e8184e3288 wip: edit and share button 2023-09-03 18:34:56 +06:00
Arik Chakma
2a03fd4e17 Merge branch 'master' into feat/roadmap 2023-09-03 17:48:48 +06:00
Arik Chakma
a85c2d847c Progress Tracking styles 2023-09-02 17:31:33 +06:00
Arik Chakma
4d196af4a6 Shortcut progress 2023-09-02 07:39:54 +06:00
Arik Chakma
c32b332e51 Render progress 2023-09-02 06:32:46 +06:00
Arik Chakma
c8e79b4991 wip: custom roadmap progress 2023-09-02 05:38:56 +06:00
Arik Chakma
1a26a56c81 wip: svg styles 2023-09-02 04:26:49 +06:00
Arik Chakma
5de509d6be wip: roadmap content 2023-09-02 03:35:08 +06:00
Arik Chakma
d9202f334c Merge branch 'master' into feat/roadmap 2023-09-02 01:36:50 +06:00
Arik Chakma
f86a1644d2 wip: custom roadmap events 2023-09-02 01:02:28 +06:00
Arik Chakma
b85623721b wip: custom roadmap renderer 2023-09-02 00:09:13 +06:00
118 changed files with 16850 additions and 1090 deletions

View File

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

View File

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

5
.gitignore vendored
View File

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

11028
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

431
pnpm-lock.yaml generated
View File

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

14
renderer/index.tsx Normal file
View File

@@ -0,0 +1,14 @@
export function Renderer(props: any) {
return (
<div className="fixed bottom-0 left-0 right-0 top-0 z-[9999] border bg-white p-5 text-black">
<h2 className="mb-2 text-xl font-semibold">Private Component</h2>
<p className="mb-4">
Renderer is a private component. If you are a collaborator and have
access to it. Run the following command:
</p>
<code className="mt-5 rounded-md bg-gray-800 p-2 text-white">
npm run generate-renderer
</code>
</div>
);
}

5
renderer/renderer.ts Normal file
View File

@@ -0,0 +1,5 @@
export function renderFlowJSON(data: any, options?: any) {
console.warn("renderFlowJSON is not implemented");
console.warn("run the following command to generate the renderer:");
console.warn("> npm run generate-renderer");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,9 @@ export function Step2(props: Step2Props) {
<>
<div className="mt-4 flex w-full flex-col">
<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">
You can always add and customize your roadmaps later.
</p>
@@ -25,12 +27,12 @@ export function Step2(props: Step2Props) {
<RoadmapSelector
teamId={team._id!}
teamResourceConfig={teamResourceConfig}
setTeamResourceConfig={setTeamResourceConfig}
teamResources={teamResourceConfig}
setTeamResources={setTeamResourceConfig}
/>
</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
type="button"
onClick={onBack}
@@ -46,8 +48,9 @@ export function Step2(props: Step2Props) {
<button
type="button"
onClick={onNext}
disabled={teamResourceConfig.length !== 0}
className={
'flex-grow rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black md:flex-auto'
'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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
svg text tspan {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeSpeed;
}
svg > g[data-type='topic'],
svg > g[data-type='subtopic'],
svg > g > g[data-type='link-item'],
svg > g[data-type='button'] {
cursor: pointer;
}
svg > g[data-type='topic']:hover > rect {
fill: #d6d700;
}
svg > g[data-type='subtopic']:hover > rect {
fill: #f3c950;
}
svg > g[data-type='button']:hover {
opacity: 0.8;
}
svg .done rect {
fill: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
}
svg > g[data-type='topic'].learning > rect + text,
svg > g[data-type='topic'].done > rect + text {
fill: black;
}
svg > g[data-type='subtipic'].done > rect + text,
svg > g[data-type='subtipic'].learning > rect + text {
fill: #cbcbcb;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .learning text {
text-decoration: underline;
}
svg .skipped rect {
fill: #496b69 !important;
}

View File

@@ -0,0 +1,177 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Renderer } from '../../../renderer';
import './RoadmapRenderer.css';
import {
renderResourceProgress,
updateResourceProgress,
type ResourceProgressType,
renderTopicProgress,
refreshProgressCounters,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { EmptyRoadmap } from './EmptyRoadmap';
import { isLoggedIn } from '../../lib/jwt';
import { httpPost } from '../../lib/http';
type RoadmapRendererProps = {
roadmap: RoadmapDocument;
};
type RoadmapNodeDetails = {
nodeId: string;
nodeType: string;
targetGroup: SVGElement;
};
export function getNodeDetails(
svgElement: SVGElement
): RoadmapNodeDetails | null {
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
const nodeId = targetGroup?.dataset?.nodeId;
const nodeType = targetGroup?.dataset?.type;
if (!nodeId || !nodeType) return null;
return { nodeId, nodeType, targetGroup };
}
export const allowedClickableNodeTypes = [
'topic',
'subtopic',
'button',
'link-item',
];
export function RoadmapRenderer(props: RoadmapRendererProps) {
const { roadmap } = props;
const roadmapRef = useRef<HTMLDivElement>(null);
const roadmapId = roadmap._id!;
const toast = useToast();
const [hideRenderer, setHideRenderer] = useState(false);
async function updateTopicStatus(
topicId: string,
newStatus: ResourceProgressType
) {
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId: roadmapId,
resourceType: 'roadmap',
topicId,
},
newStatus
)
.then(() => {
renderTopicProgress(topicId, newStatus);
})
.catch((err) => {
toast.error('Something went wrong, please try again.');
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
refreshProgressCounters();
});
return;
}
const handleSvgClick = useCallback((e: MouseEvent) => {
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
return;
if (nodeType === 'button' || nodeType === 'link-item') {
const link = targetGroup?.dataset?.link || '';
const isExternalLink = link.startsWith('http');
if (isExternalLink) {
window.open(link, '_blank');
} else {
window.location.href = link;
}
return;
}
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
if (e.shiftKey) {
e.preventDefault();
updateTopicStatus(
nodeId,
isCurrentStatusLearning ? 'pending' : 'learning'
);
return;
} else if (e.altKey) {
e.preventDefault();
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
return;
}
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
topicId: nodeId,
resourceId: roadmap?._id,
resourceType: 'roadmap',
isCustomResource: true,
},
})
);
}, []);
const handleSvgRightClick = useCallback((e: MouseEvent) => {
e.preventDefault();
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
return;
if (nodeType === 'button' || nodeType === 'link-item') {
return;
}
const isCurrentStatusDone = targetGroup?.classList.contains('done');
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
}, []);
useEffect(() => {
if (!roadmapRef?.current) return;
roadmapRef?.current?.addEventListener('click', handleSvgClick);
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
return () => {
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
roadmapRef?.current?.removeEventListener(
'contextmenu',
handleSvgRightClick
);
};
}, []);
return (
<div className="flex grow bg-gray-50 pb-8 pt-4 sm:pt-12">
<div className="container !max-w-[1000px]">
<Renderer
ref={roadmapRef}
roadmap={{ nodes: roadmap?.nodes!, edges: roadmap?.edges! }}
onRendered={() => {
renderResourceProgress('roadmap', roadmapId).then(() => {
if (roadmap?.nodes?.length === 0) {
setHideRenderer(true);
roadmapRef?.current?.classList.add('hidden');
}
});
}}
/>
{hideRenderer && <EmptyRoadmap />}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -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 {
featuredItems: FeaturedItemType[];
heading: string;
showCreateRoadmap?: 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'>
@@ -32,6 +39,19 @@ const { featuredItems, heading, allowBookmark = true } = Astro.props;
</li>
))
}
{
showCreateRoadmap && (
<li>
<CreateRoadmapButton
client:load
className='min-h-[54px]'
type={
heading.toLowerCase().indexOf('role') > -1 ? 'role' : 'skill'
}
/>
</li>
)
}
</ul>
</div>
</div>

View File

@@ -10,7 +10,10 @@ import { AddUserIcon } from '../ReactIcons/AddUserIcon';
type FriendProgressItemProps = {
friend: ListFriendsResponse[0];
onShowResourceProgress: (resourceId: string) => void;
onShowResourceProgress: (
resourceId: string,
isCustomResource?: boolean
) => void;
onReload: () => void;
};
@@ -52,7 +55,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
onReload();
}
const roadmaps = (friend.roadmaps || []).sort((a, b) => {
const roadmaps = (friend?.roadmaps || []).sort((a, b) => {
return b.done - a.done;
});
@@ -86,7 +89,12 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
{(showAll ? roadmaps : roadmaps.slice(0, 4)).map((progress) => {
return (
<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"
key={progress.resourceId}
>

View File

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

View File

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

View File

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

View File

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

46
src/components/Modal.tsx Normal file
View File

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

View File

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

View File

@@ -0,0 +1,52 @@
import { useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt';
import { AccountDropdownList } from './AccountDropdownList';
import { DropdownTeamList } from './DropdownTeamList';
import { useOutsideClick } from '../../hooks/use-outside-click';
export function AccountDropdown() {
const dropdownRef = useRef(null);
const [showDropdown, setShowDropdown] = useState(false);
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
useOutsideClick(dropdownRef, () => {
setShowDropdown(false);
setIsTeamsOpen(false);
});
if (!isLoggedIn()) {
return null;
}
return (
<div className="relative z-50 animate-fade-in">
<button
className="flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
onClick={() => {
setIsTeamsOpen(false);
setShowDropdown(!showDropdown);
}}
>
<span className="inline-flex items-center">
Account&nbsp;<span className="text-gray-300">/</span>&nbsp;Teams
</span>
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
</button>
{showDropdown && (
<div
ref={dropdownRef}
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
>
{isTeamsOpen ? (
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
) : (
<AccountDropdownList setIsTeamsOpen={setIsTeamsOpen} />
)}
</div>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -112,7 +112,7 @@ export function TeamDropdown() {
)}
</span>
<button
className="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)}
>
{pendingTeamIds.length > 0 && (

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,10 @@ import { useState } from 'react';
type MemberProgressItemProps = {
member: TeamMember;
onShowResourceProgress: (resourceId: string) => void;
onShowResourceProgress: (
resourceId: string,
isCustomResource: boolean
) => void;
isMyProgress?: boolean;
};
export function MemberProgressItem(props: MemberProgressItemProps) {
@@ -29,7 +32,7 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
: '/images/default-avatar.png'
}
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">
{!isMyProgress && (
@@ -51,7 +54,12 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
(progress) => {
return (
<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"
key={progress.resourceId}
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ export function Toaster(props: Props) {
onClick={() => {
$toastMessage.set(undefined);
}}
className={`fixed bottom-5 left-1/2 z-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
className={`flex -translate-x-1/2 transform cursor-pointer items-center gap-2 rounded-md border border-gray-200 bg-white py-3 pl-4 pr-5 text-black shadow-md hover:bg-gray-50`}

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -7,4 +7,4 @@ Visit the following resources to learn more:
- [throttleTime](https://rxjs.dev/api/operators/throttleTime)
- [sampleTime](https://rxjs.dev/api/operators/sampleTime)
- [auditTime](https://rxjs.dev/api/operators/auditTime)
- [Blogs and tutorials on RxJS](https://blog.angular-university.io/rxjs-better-performance-with-the-rxjs-audittime-operator/)
- [Blogs and tutorials on RxJS](https://blog.angular-university.io/functional-reactive-programming-for-angular-2-developers-rxjs-and-observables/)

View File

@@ -7,6 +7,7 @@ Visit the following resources to learn more:
- [Visit Dedicated Go Roadmap](/golang)
- [A Tour of Go Go Basics](https://go.dev/tour/welcome/1)
- [Go Reference Documentation](https://go.dev/doc/)
- [Learn Go | Boot.dev](https://boot.dev/learn/learn-golang)
- [Go by Example - annotated example programs](https://gobyexample.com/)
- [Learn Go | Codecademy](https://www.codecademy.com/learn/learn-go)
- [W3Schools Go Tutorial ](https://www.w3schools.com/go/)

View File

@@ -1,8 +1,10 @@
# Cors
Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources.
Visit the following resources to learn more:
- [Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
- [CORS in 100 Seconds](https://www.youtube.com/watch?v=4KHiSt0oLJ0)
- [CORS in 6 minutes](https://www.youtube.com/watch?v=PNtFSVU-YTI)
- [Understanding CORS](https://rbika.com/blog/understanding-cors)

View File

@@ -12,4 +12,4 @@ Throttling is an important aspect of cloud design, as it helps to ensure that re
Visit the following resources to learn more:
- [Throttling - AWS Well-Architected Framework](https://aws.amazon.com/architecture/well-architected/serverless/patterns/throttling/)
- [Throttling - AWS Well-Architected Framework](https://docs.aws.amazon.com/wellarchitected/2022-03-31/framework/rel_mitigate_interaction_failure_throttle_requests.html)

View File

@@ -7,5 +7,6 @@ Visit the following resources to learn more:
- [What is Solana, and how does it work?](https://cointelegraph.com/news/what-is-solana-and-how-does-it-work)
- [Beginners Guide To Solana](https://solana.com/news/getting-started-with-solana-development)
- [Solana Introduction](https://docs.solana.com/introduction)
- [Solana Whitepaper](https://solana.com/solana-whitepaper.pdf)
- [Solana Architecture](https://docs.solana.com/cluster/overview)
- [Start Building Solana!](https://beta.solpg.io/?utm_source=solana.com)

View File

@@ -7,3 +7,4 @@ Many blockchains have forked the Ethereum blockchain and added functionality on
Visit the following resources to learn more:
- [What is Ethereum Virtual Machine?](https://moralis.io/evm-explained-what-is-ethereum-virtual-machine/)
- [Understanding the Ethereum Virtual Machine (EVM): Concepts and Architecture](https://www.youtube.com/watch?v=kCswGz9naZg)

View File

@@ -1,8 +1,13 @@
# Polygon
Polygon, formerly known as the Matic Network, is a scaling solution that aims to provide multiple tools to improve the speed and reduce the cost and complexities of transactions on the Ethereum blockchain.
Polygon, formerly known as the Matic Network, is a protocol that allows anyone to create and exchange value, powered by zero-knowledge technology. Polygon provides multiple solutions including
- [Polygon zkEVM](https://polygon.technology/polygon-zkevm), a zk powered EVM equivalent L2
- [Polygon PoS](https://polygon.technology/polygon-pos), a proof of stake, EVM compatible side chain
- [Polygon CDK](https://polygon.technology/polygon-cdk), a Chain Development Kit for building customizable zk powered L2s
- [Polygon ID](https://polygon.technology/polygon-id), identity infrastructure and SDKs to facilitate trusted and secure relationships between apps and users
Visit the following resources to learn more:
- [Polygon whitepaper](https://polygon.technology/lightpaper-polygon.pdf)
- [Introduction to Polygon](https://wiki.polygon.technology/docs/develop/getting-started)
- [Introduction to Polygon](https://wiki.polygon.technology/)
- [Polygon POL whitepaper](https://polygon.technology/papers/pol-whitepaper)

View File

@@ -8,3 +8,4 @@ Visit the following resources to learn more:
- [Layer-1 and Layer-2 Blockchain Scaling Solutions](https://www.gemini.com/cryptopedia/blockchain-layer-2-network-layer-1-network)
- [Layer 2 - Binance Academy](https://academy.binance.com/en/glossary/layer-2)
- [Develop a ZK-powered Layer 2 with the Polgyon CDK open-source framework](https://wiki.polygon.technology/docs/cdk/)

View File

@@ -0,0 +1,18 @@
# Polygon zkEVM
Polygon zkEVM is a decentralized Ethereum Layer 2 scalability solution that uses cryptographic zero-knowledge proofs to offer validity and quick finality to off-chain transaction computation, also known as a ZK-Rollup.
The ZK-Rollup executes smart contracts transparently, by publishing zero-knowledge validity proofs, while maintaining opcode compatibility with the Ethereum Virtual Machine.
Benefits of Polygon zkEVM
- EVM-equivalence
- Ethereum security
- ZKP-powered scalability
Visit the following resources to learn more:
- [Introduction to Polygon zkEVM](https://wiki.polygon.technology/docs/zkevm/introduction/)
- [Polygon zkEVM Quickstart](https://wiki.polygon.technology/docs/zkevm/develop/)
- [Polygon zkEVM Faucet Guide](https://wiki.polygon.technology/docs/zkevm/guides/zkevm-faucet/)
- [Polygon zkEVM Asset Bridging Guide](https://wiki.polygon.technology/docs/zkevm/bridge-to-zkevm/)

View File

@@ -7,6 +7,7 @@ Visit the following resources to learn more:
- [Visit Dedicated Go Roadmap](/golang)
- [A Tour of Go Go Basics](https://go.dev/tour/welcome/1)
- [Go Reference Documentation](https://go.dev/doc/)
- [Learn Go | Boot.dev](https://boot.dev/learn/learn-golang)
- [Go by Example - annotated example programs](https://gobyexample.com/)
- [Learn Go | Codecademy](https://www.codecademy.com/learn/learn-go)
- [W3Schools Go Tutorial ](https://www.w3schools.com/go/)

View File

@@ -8,6 +8,7 @@ Visit the following resources to learn more:
- [A Tour of Go Go Basics](https://go.dev/tour/welcome/1)
- [Go Reference Documentation](https://go.dev/doc/)
- [Go by Example - annotated example programs](https://gobyexample.com/)
- [Learn Go | Boot.dev](https://boot.dev/learn/learn-golang)
- [Learn Go | Codecademy](https://www.codecademy.com/learn/learn-go)
- [W3Schools Go Tutorial ](https://www.w3schools.com/go/)
- [Making a RESTful JSON API in Go](https://thenewstack.io/make-a-restful-json-api-go/)

View File

@@ -6,7 +6,7 @@ In this section, we will dive deeper into the following topics:
- [What is salting?](#what-is-salting)
- [Why is salting important?](#why-is-salting-important)
- [How dosalting work?](#how-does-salting-work)
- [How does salting work?](#how-does-salting-work)
- [Best practices for salting](#best-practices-for-salting)
---

View File

@@ -7,6 +7,7 @@ Visit the following resources to learn more:
- [Visit Dedicated Go Roadmap](/golang)
- [A Tour of Go Go Basics](https://go.dev/tour/welcome/1)
- [Go Reference Documentation](https://go.dev/doc/)
- [Learn Go | Boot.dev](https://boot.dev/learn/learn-golang)
- [Go by Example - annotated example programs](https://gobyexample.com/)
- [Learn Go | Codecademy](https://www.codecademy.com/learn/learn-go)
- [W3Schools Go Tutorial ](https://www.w3schools.com/go/)

View File

@@ -4,4 +4,7 @@ Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allo
Visit the following resources to learn more:
- [CORS — Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
- [Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
- [CORS in 100 Seconds](https://www.youtube.com/watch?v=4KHiSt0oLJ0)
- [CORS in 6 minutes](https://www.youtube.com/watch?v=PNtFSVU-YTI)
- [Understanding CORS](https://rbika.com/blog/understanding-cors)

View File

@@ -6,4 +6,5 @@ Visit the following resources to learn more:
- [throw statement - w3schools](https://www.w3schools.com/jsref/jsref_throw.asp)
- [JavaScript MDN Docs](https://developer.mozilla.org/en-us/docs/web/javascript/reference/statements/throw)
- [Error Handling](https://javascript.info/error-handling)
- [Error Handling](https://javascript.info/error-handling)
- ["Throw" operator](https://javascript.info/try-catch#throw-operator)

View File

@@ -7,3 +7,4 @@ Visit the following resources to learn more:
- [The Node.Js Event Loop](https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#what-is-the-event-loop)
- [JavaScript Visualized: Event Loop](https://dev.to/lydiahallie/javascript-visualized-event-loop-3dif)
- [What the heck is the event loop anyway?](https://www.youtube.com/watch?v=8aGhZQkoFbQ)
- [In the loop: JS conf 2018](https://www.youtube.com/watch?v=cCOL7MC4Pl0)

View File

@@ -1,6 +1,6 @@
# Promises
A promise is commonly defined as a proxy for a value that will eventually become available
A promise is commonly defined as a proxy for a value that will eventually become available.
Asynchronous functions use promise behind the scenes, so understanding how promises work is fundamental to understanding how "async" and "await" works.
Once a promise has been called, it will start in a pending state. This means that the calling function continues executing, while the promise is pending until it resolves, giving the calling function whatever data was being requested.
@@ -13,5 +13,5 @@ Visit the following resources to learn more:
- [Promise Methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)
- [Official Website](https://www.promisejs.org/)
- [Official Website](https://www.freecodecamp.org/news/javascript-promises-for-beginners/)
- [JavaScript Promises for Beginners](https://www.freecodecamp.org/news/javascript-promises-for-beginners/)
- [Asynchronous JavaScript - Promises](https://www.youtube.com/watch?v=a_8nrslImo4/)

View File

@@ -7,7 +7,6 @@ Built-in modules are already installed with `Node.js`, so you don't need to inst
- **net**: to build clients and servers.
- **path**: to handle file paths.
- **url**: help in parsing URL strings.
- **events**: provides a method for interacting with events.
- **http**: making Node.js transfer data over HTTP.
- **console**: to log information in the console.
- **assert**: provides a set of assertion tests.

View File

@@ -6,5 +6,7 @@ Zustand is often used as an alternative to other state management libraries, suc
Visit the following resources to learn more:
- [Zustand - Official Website](https://github.com/pmndrs/zustand)
- [Working with Zustand](https://tkdodo.eu/blog/working-with-zustand)
- [Zustand - Official Documentation](https://docs.pmnd.rs/zustand/getting-started/introduction)
- [pmndrs/zustand](https://github.com/pmndrs/zustand)
- [pmndrs/zustand](https://github.com/pmndrs/zustand)

View File

@@ -7,6 +7,7 @@ Visit the following resources to learn more:
- [Visit Dedicated Go Roadmap](/golang)
- [A Tour of Go Go Basics](https://go.dev/tour/welcome/1)
- [Go Reference Documentation](https://go.dev/doc/)
- [Learn Go | Boot.dev](https://boot.dev/learn/learn-golang)
- [Go by Example - annotated example programs](https://gobyexample.com/)
- [Learn Go | Codecademy](https://www.codecademy.com/learn/learn-go)
- [W3Schools Go Tutorial ](https://www.w3schools.com/go/)

View File

@@ -12,3 +12,4 @@ Here are a few key differences between TypeScript and JavaScript:
Learn more from the following links:
- [Learning JavaScript and TypeScript](https://www.typescriptlang.org/docs/handbook/typescript-from-scratch.html#learning-javascript-and-typescript)
- [TypeScript vs. JavaScript](https://thenewstack.io/typescript-vs-javascript/)

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