Compare commits

...

74 Commits

Author SHA1 Message Date
Arik Chakma
27d2bcb72d Fix editor link 2023-10-16 22:36:30 +06:00
Arik Chakma
6b395de616 Add share with others button 2023-10-16 21:28:28 +06:00
Arik Chakma
06d9b3971a Add Edit button in the roadmap list 2023-10-16 20:30:32 +06:00
Kamran Ahmed
b06e82de5f Sponsor for nginx 2023-10-13 22:41:46 +01:00
Kamran Ahmed
d65ecac777 Account dropdown changes 2023-10-13 19:52:21 +01:00
Kamran Ahmed
c46d962803 Add links to questions 2023-10-12 21:22:01 +01:00
Kamran Ahmed
bd4e7ea3d0 Add links to questions 2023-10-12 21:20:21 +01:00
Kamran Ahmed
252b083a48 add roadmap editor image 2023-10-12 20:56:01 +01:00
Arik Chakma
abbeb717d1 Add JavaScript questions (#4505)
* Add Javascript questions

* wip: add more questions

* wip: add ternary operator

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* fix: set example

* wip: add more questions

* wip: add more question

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add another question

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions
2023-10-12 15:03:19 +01:00
Kamran Ahmed
485ca9dd8f Spring testing link fix 2023-10-11 14:32:40 +01:00
Kamran Ahmed
c3315fb41e Fix typo on teams page 2023-10-11 12:56:56 +01:00
Kamran Ahmed
6ed436674f Discovery page option in sharing 2023-10-10 00:12:05 +01:00
Kamran Ahmed
76c6c4dc1f isDiscoverable not persisted 2023-10-10 00:06:24 +01:00
Kamran Ahmed
cb56e85651 Discoverable option selection 2023-10-09 23:27:49 +01:00
Kamran Ahmed
dcf740e275 Update share buttons text 2023-10-09 21:57:33 +01:00
Arik Chakma
16662ed699 Implement Social Share options (#4569)
* Implement social share options

* Minor fix
2023-10-09 21:49:21 +01:00
Kamran Ahmed
6f9fe361ae Change style of custom roadmap page 2023-10-09 09:07:59 +01:00
Arik Chakma
036b34c6f3 Implement Custom Roadmap minor features (#4565)
* Remove roadmap type

* Add Edit Roadmap button

* Add Edit Roadmap permission

* Add Edit and Share roadmap button

* Remove Margin

* Implement Discoverable Checkbox

* Add Loading State for buttons
2023-10-09 08:44:30 +01:00
Kamran Ahmed
93c2043f23 Fix warning in hero roadmap 2023-10-08 18:38:02 +01:00
Saleh Hashemi
d2da3c8621 update checkout version to v4 (#4559) 2023-10-07 22:15:01 +01:00
Kamran Ahmed
4aa8f15c07 Add email icon in footer 2023-10-07 15:31:24 +01:00
Arik Chakma
ceb4c3b95d Remove invited members from sharing settings (#4555)
* Fix team member list

* Minor change
2023-10-06 19:00:48 +01:00
Kamran Ahmed
7ec5e30b51 Hero roadmap section updates 2023-10-06 19:00:08 +01:00
Kamran Ahmed
e5e0a7c8c5 Add teams banner 2023-10-04 20:32:28 +01:00
Kamran Ahmed
90f3ffe270 Add banner for teams 2023-10-04 16:13:41 +01:00
Kamran Ahmed
ce47a7433e Teams button in navigation 2023-10-04 15:44:34 +01:00
Selva Muthu Kumaran
21b8358683 roadmap-aspnet-change-tracker-api.md (#4546)
aspnet-change-tracker-api URL fixed
fixes : #4544
2023-10-04 20:40:45 +06:00
Kamran Ahmed
e1751b105f Add team page 2023-10-04 15:28:46 +01:00
Kamran Ahmed
e43bea7c40 Setup redirects on the teams page 2023-10-04 15:22:20 +01:00
Kamran Ahmed
5fa669aec2 Update team page 2023-10-04 15:06:59 +01:00
Kamran Ahmed
4b8f868b2b Add roadmaps and friends to account dropdown 2023-10-04 10:34:29 +01:00
Kamran Ahmed
a0743a8272 Fix sharing options button 2023-10-04 10:30:28 +01:00
Arik Chakma
2cae13c090 Add Members while Transferring Roadmap (#4534)
* Add members while Transferring Roadmap

* Implement Responsive in Roadmaps page
2023-10-04 10:15:56 +01:00
Kamran Ahmed
0bf287f1d6 Add features to pricing 2023-10-04 10:12:08 +01:00
Kamran Ahmed
d7d819b4b3 Add teams introduction page 2023-10-03 21:07:53 +01:00
Kamran Ahmed
29cff6a6f8 Update badge 2023-10-02 17:34:51 +01:00
Kamran Ahmed
044df81b7a Creator details on roadmap page 2023-10-02 17:03:20 +01:00
Arik Chakma
3151ee5021 Add Creator Details (#4530)
* Add Creator details

* Add Skeleton Loading
2023-10-02 16:37:30 +01:00
Kamran Ahmed
e6ce9f40ee Update roadmap contribution template 2023-10-02 15:50:42 +01:00
Kamran Ahmed
3b5e3c44f9 Update label for roadmap creation 2023-10-02 15:10:48 +01:00
Kamran Ahmed
c286e0a6f8 Increase max team member count 2023-10-01 03:24:16 +01:00
linxiaowang
3bebe0c1de fix(typo): fix typo in 101-instanceof-operator.md (#4514) 2023-10-01 03:00:55 +06:00
Sherkhan Azimov
9845fe624a separate articles in 107-domain-name-system.md (#4517)
Transfer an article to a new line
2023-10-01 02:59:02 +06:00
Nicky Lim
4b2b2ebe8c Fix typo cpp 104 index (#4520) 2023-09-30 21:40:03 +06:00
Arik Chakma
82c2aaacc3 Fix Roadmap Share Link (#4522) 2023-09-30 14:48:04 +01:00
Kamran Ahmed
6d1edb76c7 Fix failing build 2023-09-30 14:28:16 +01:00
Kamran Ahmed
5d57d5baaf Update deployment workflow 2023-09-30 14:25:25 +01:00
Kamran Ahmed
d31d626c61 Update deployment workflow 2023-09-30 14:23:00 +01:00
Kamran Ahmed
71bf34e683 Add personal token 2023-09-30 14:17:47 +01:00
Kamran Ahmed
93a91b1d9b Fix failing build 2023-09-30 14:14:47 +01:00
Kamran Ahmed
18c8bd14b2 Fix failing build 2023-09-30 14:13:06 +01:00
Kamran Ahmed
e34695e334 Fix failing build 2023-09-30 14:03:49 +01:00
Arik Chakma
8310671123 Allow creating custom roadmaps (#4486)
* wip: custom roadmap renderer

* wip: custom roadmap events

* wip: roadmap content

* wip: svg styles

* wip: custom roadmap progress

* Render progress

* Shortcut progress

* Progress Tracking styles

* wip: edit and share button

* fix: disabled the share button

* wip: content links rendering

* Fix progress share

* Replace disabled with `canShare`

* wip: show custom roadmaps

* wip: users all roadmaps

* fix: create roadmap api

* chore: roadmap sidebar icon

* wip: content links

* Update links color

* Create roadmap home

* Create Roadmap button

* Roadmap type

* chore: share progress modal

* wip: share roadmap

* wip: change visibility

* chore: custom roadmap progress in activity

* wip: custom roadmap share progress

* chore: friend's roadmap

* wip: custom roadmap skeleton

* chore: roadmap title

* Restricted Page

* fix: skeleton loading width

* Fix create roadmap button

* chore: remove user id

* chore: pick roadmap and share

* chore: open new tab on create roadmap

* chore: change share title

* chore: use team id from params

* chore: team roadmap create modal

* chore: create team roadmap

* chore: custom roadmap modal

* chore: placeholde roadmaps

* chore: roadmap hint

* chore: visibility label

* chore: public roadmap

* chore: empty screen

* chore: team progress

* chore: create roadmap responsive

* chore: form error

* chore: multi user history

* wip: manage custom roadmap

* chore: empty roadmap list

* chore: custom roadmap visit

* chore: shared roadmaps

* chore: shared roadmaps

* chore: empty screen and topic title

* chore: show progress bar

* Implement Error in topic details

* Add Modal close button

* fix: link groups

* Refactor roadmap creation

* Refactor roadmap creation

* Refactor team creation

* Refactor team roadmaps

* Refactor team creation roadmap selection

* Refactor

* Refactor team roadmap loading

* Refactor team roadmaps

* Refactor team roadmaps listing

* Refactor Account dropdown

* Updates

* Refactor Account dropdown

* Fix Team name overflow

* Change Icon color

* Update team dropdown

* Minor UI fixes

* Fix minor UI

* Flicker fix in team dropdown

* Roadmap action dropdown with responsiveness

* Team roadmaps listing

* Update team settings

* Team roadmaps listing

* fix: remove visibility change

* Update roadmap options modal

* Add dummy renderer

* Add renderer script

* Add generate renderer script

* Add generate renderer

* wip: add share settings

* Update

* Update UI

* Update Minor UI

* Fix team issue

* Update Personal roadmaps UI

* Add Roadmap Secret

* Update teams type

* Rearrange sections

* Change Secret name

* Add action button on roadmap detail page

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-30 13:55:24 +01:00
Kamran Ahmed
d45c8f9cb2 Update coursera links 2023-09-29 17:26:54 +01:00
Kamran Ahmed
573263ed74 Fix back button not working 2023-09-26 21:18:35 +01:00
Kamran Ahmed
f27aa58ac3 Fix back button not working 2023-09-26 21:14:26 +01:00
Saleh Hashemi
518cf4ce73 Fix broken Git Tutorial for Dummies link 2023-09-26 19:18:42 +06: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
225 changed files with 19452 additions and 1190 deletions

View File

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

View File

@@ -14,24 +14,12 @@ body:
placeholder: e.g. Roadmap to learn Data Science
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: Is this roadmap prepared by you or someone else?
options:
- I prepared this roadmap
- I found this roadmap online (please provide a link below)
- type: textarea
id: roadmap-description
attributes:
label: Roadmap Items
description: Please submit a nested list of items which we can convert into the visual. Here is an [example of roadmap items list.](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5).
label: Roadmap Link
description: Please create the roadmap [using our roadmap editor](https://twitter.com/kamrify/status/1708293162693767426) and submit the roadmap link.
placeholder: |
- Item 1
- Subitem 1
- Subitem 2
- Item 2
- Subitem 1
- Subitem 2
https://roadmap.sh/xyz
validations:
required: true
required: true

View File

@@ -4,21 +4,23 @@ on:
branches: [ master ]
env:
PUBLIC_API_URL: "https://api.roadmap.sh"
PUBLIC_EDITOR_APP_URL: "https://draw.roadmap.sh"
PUBLIC_AVATAR_BASE_URL: "https://dodrc8eu8m09s.cloudfront.net/avatars"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PAT: ${{ secrets.PAT }}
CI: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v1
with:
node-version: 18
- run: git config --global url."https://${{ secrets.PAT }}@github.com/".insteadOf ssh://git@github.com/
- name: Prepare Draw Repository
run: |
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
- uses: pnpm/action-setup@v2.2.2
with:
version: 7.13.4
@@ -27,6 +29,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

View File

@@ -9,7 +9,7 @@ jobs:
upgrade-deps:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: 18

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

@@ -9,8 +9,8 @@
<a href="https://roadmap.sh/best-practices">
<img src="https://img.shields.io/badge/%E2%9C%A8-Best%20Practices-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="best practices" />
</a>
<a href="https://youtube.com/theroadmap?sub_confirmation=1">
<img src="https://img.shields.io/badge/%E2%9C%A8-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
<a href="https://roadmap.sh/questions">
<img src="https://img.shields.io/badge/%E2%9C%A8-Questions-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
</a>
<a href="https://www.youtube.com/channel/UCA0H2KIWgWTwpTFjSxp0now?sub_confirmation=1">
<img src="https://img.shields.io/badge/%E2%9C%A8-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
@@ -24,7 +24,7 @@
Roadmaps are now interactive, you can click the nodes to read more about the topics.
### [View all Roadmaps](https://roadmap.sh)
### [View all Roadmaps](https://roadmap.sh) &nbsp;&middot;&nbsp; [Best Practices](https://roadmap.sh/best-practices) &nbsp;&middot;&nbsp; [Questions](https://roadmap.sh/questions)
![](https://i.imgur.com/waxVImv.png)
@@ -67,13 +67,18 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Docker Roadmap](https://roadmap.sh/docker)
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
We have also added a new form of visual content covering best practices:
There are also interactive best practices:
- [Code Review Best Practices](https://roadmap.sh/best-practices/code-review)
- [Frontend Performance Best Practices](https://roadmap.sh/best-practices/frontend-performance)
- [API Security Best Practices](https://roadmap.sh/best-practices/api-security)
- [AWS Best Practices](https://roadmap.sh/best-practices/aws)
..and questions to help you test, rate and improve your knowledge
- [JavaScript Questions](https://roadmap.sh/questions/javascript)
- [React Questions](https://roadmap.sh/questions/react)
![](https://i.imgur.com/waxVImv.png)
## Share with the community

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,33 @@
#!/usr/bin/env bash
set -e
# ignore cloning if .temp/web-draw already exists
if [ ! -d ".temp/web-draw" ]; then
mkdir -p .temp
git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw
fi
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
# 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

@@ -56,6 +56,12 @@ export function GitHubButton(props: GitHubButtonProps) {
}
}
const authRedirectUrl = localStorage.getItem('authRedirect');
if (authRedirectUrl) {
localStorage.removeItem('authRedirect');
redirectUrl = authRedirectUrl;
}
localStorage.removeItem(GITHUB_REDIRECT_AT);
localStorage.removeItem(GITHUB_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {

View File

@@ -55,6 +55,12 @@ export function GoogleButton(props: GoogleButtonProps) {
}
}
const authRedirectUrl = localStorage.getItem('authRedirect');
if (authRedirectUrl) {
localStorage.removeItem('authRedirect');
redirectUrl = authRedirectUrl;
}
localStorage.removeItem(GOOGLE_REDIRECT_AT);
localStorage.removeItem(GOOGLE_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
@@ -86,10 +92,11 @@ export function GoogleButton(props: GoogleButtonProps) {
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath =
['/respond-invite', '/befriend'].includes(window.location.pathname)
? window.location.pathname + window.location.search
: window.location.pathname;
const pagePath = ['/respond-invite', '/befriend'].includes(
window.location.pathname
)
? window.location.pathname + window.location.search
: window.location.pathname;
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);

View File

@@ -55,6 +55,12 @@ export function LinkedInButton(props: LinkedInButtonProps) {
}
}
const authRedirectUrl = localStorage.getItem('authRedirect');
if (authRedirectUrl) {
localStorage.removeItem('authRedirect');
redirectUrl = authRedirectUrl;
}
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
localStorage.removeItem(LINKEDIN_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {

View File

@@ -36,6 +36,7 @@ function handleGuest() {
'/account/notification',
'/account/update-password',
'/account/settings',
'/account/roadmaps',
'/account/road-card',
'/account/friends',
'/account',
@@ -72,7 +73,10 @@ function handleAuthenticated() {
// If the user is on a guest route, redirect them to the home page
if (guestRoutes.includes(window.location.pathname)) {
window.location.href = '/';
const authRedirect = window.localStorage.getItem('authRedirect') || '/';
window.localStorage.removeItem('authRedirect');
window.location.href = authRedirect;
}
}

View File

@@ -40,6 +40,14 @@ const defaultPages: PageType[] = [
icon: GroupIcon.src,
isProtected: true,
},
{
id: 'friends',
url: '/account/friends',
title: 'Friends',
group: 'Pages',
icon: GroupIcon.src,
isProtected: true,
},
{
id: 'roadmaps',
url: '/roadmaps',
@@ -47,6 +55,14 @@ const defaultPages: PageType[] = [
group: 'Pages',
icon: RoadmapIcon.src,
},
{
id: 'account-roadmaps',
url: '/account/roadmaps',
title: 'Custom Roadmaps',
group: 'Pages',
icon: RoadmapIcon.src,
isProtected: true,
},
{
id: 'best-practices',
url: '/best-practices',

View File

@@ -1,37 +1,53 @@
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;
description?: 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 +88,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
return;
}
setTeamResourceConfig(response);
setTeamResources(response);
}
async function onRemove(resourceId: string) {
@@ -106,13 +122,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 +149,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 +159,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
{showSelectRoadmapModal && (
<SelectRoadmapModal
onClose={() => setShowSelectRoadmapModal(false)}
teamResourceConfig={teamResourceConfig}
teamResourceConfig={teamResources}
allRoadmaps={allRoadmaps}
teamId={teamId}
onRoadmapAdd={(roadmapId) => {
@@ -145,72 +173,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,50 @@
import { Plus } from 'lucide-react';
import { isLoggedIn } from '../../../lib/jwt';
import { showLoginPopup } from '../../../lib/popup';
import { cn } from '../../../lib/classname';
import { CreateRoadmapModal } from './CreateRoadmapModal';
import { useState } from 'react';
type CreateRoadmapButtonProps = {
className?: string;
text?: string;
teamId?: string;
};
export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
const { teamId, className, text = 'Create your own Roadmap' } = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
function toggleCreateRoadmapHandler() {
if (!isLoggedIn()) {
return showLoginPopup();
}
setIsCreatingRoadmap(true);
}
return (
<>
{isCreatingRoadmap && (
<CreateRoadmapModal
teamId={teamId}
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} />
{text}
</button>
</>
);
}

View File

@@ -0,0 +1,244 @@
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';
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;
isDiscoverable: boolean;
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;
visibility?: AllowedRoadmapVisibility;
}
export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
const { onClose, onCreated, teamId } = props;
const titleRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
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) {
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,
...(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('');
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 text-black 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 text-black 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={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,133 @@
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 type CreatorType = {
id: string;
name: string;
avatar: string;
};
export type GetRoadmapResponse = RoadmapDocument & {
canManage: boolean;
creator?: CreatorType;
team?: CreatorType;
};
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<GetRoadmapResponse | 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<GetRoadmapResponse>(
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,30 @@
import {CircleSlash, PenSquare, Shapes} from 'lucide-react';
type EmptyRoadmapProps = {
roadmapId: string;
canManage: boolean;
};
export function EmptyRoadmap(props: EmptyRoadmapProps) {
const { roadmapId, canManage } = props;
const editUrl = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`;
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-2">This roadmap is currently empty.</h3>
{canManage && (
<a
href={editUrl}
className="mt-4 rounded-md bg-gray-500 px-4 py-2 font-medium text-white hover:bg-gray-600 flex items-center"
>
<Shapes className="inline-block mr-2 h-4 w-4" />
Edit Roadmap
</a>
)}
</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,251 @@
import { httpDelete } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import {
ExternalLink,
Shapes,
type LucideIcon,
Globe,
LockIcon,
Users,
PenSquare,
} 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
isDiscoverable={selectedRoadmap.isDiscoverable}
description={selectedRoadmap.description}
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_172px]"
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>
<a
href={editorLink}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-800 bg-gray-900 px-2.5 py-1.5 text-xs text-white hover:bg-gray-800 focus:outline-none'
}
target={'_blank'}
>
<PenSquare className="inline-block h-4 w-4" />
Edit
</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,113 @@
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
isDiscoverable={$currentRoadmap.isDiscoverable}
description={$currentRoadmap?.description}
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,222 @@
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';
import { Lock, Shapes } from 'lucide-react';
import { Modal } from '../Modal';
import { ShareSuccess } from '../ShareOptions/ShareSuccess';
type RoadmapHeaderProps = {};
export function RoadmapHeader(props: RoadmapHeaderProps) {
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
const $currentRoadmap = useStore(currentRoadmap);
const {
title,
description,
_id: roadmapId,
creator,
team,
visibility,
} = useStore(currentRoadmap) || {};
const [isSharing, setIsSharing] = useState(false);
const [isSharingWithOthers, setIsSharingWithOthers] = 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}`;
}
}
const avatarUrl = creator?.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${creator?.avatar}`
: '/images/default-avatar.png';
const sharingWithOthersModal = isSharingWithOthers && (
<Modal
onClose={() => setIsSharingWithOthers(false)}
wrapperClassName="max-w-lg"
bodyClassName="p-4 flex flex-col"
>
<ShareSuccess
visibility="public"
roadmapId={roadmapId!}
description={description}
onClose={() => setIsSharingWithOthers(false)}
isSharingWithOthers={true}
/>
</Modal>
);
return (
<div className="border-b">
<div className="container relative py-5 sm:py-12">
{creator?.name && (
<div className="-mb-1 flex items-center gap-1.5 text-sm text-gray-500">
<img
alt={creator.name}
src={avatarUrl}
className="h-5 w-5 rounded-full"
/>
<span>
Created by&nbsp;
<span className="font-semibold text-gray-900">
{creator?.name}
</span>
{team && (
<>
&nbsp;in&nbsp;
<span className="font-semibold text-gray-900">
{team?.name}
</span>
</>
)}
</span>
</div>
)}
<div className="mb-3 mt-4 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>
<div className="flex items-center gap-2">
{$canManageCurrentRoadmap && (
<>
{isSharing && $currentRoadmap && (
<ShareOptionsModal
isDiscoverable={$currentRoadmap.isDiscoverable}
description={$currentRoadmap?.description}
visibility={$currentRoadmap?.visibility}
teamId={$currentRoadmap?.teamId}
roadmapId={$currentRoadmap?._id!}
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
sharedTeamMemberIds={
$currentRoadmap?.sharedTeamMemberIds || []
}
onClose={() => setIsSharing(false)}
onShareSettingsUpdate={(settings) => {
currentRoadmap.set({
...$currentRoadmap,
...settings,
});
}}
/>
)}
<a
href={`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
$currentRoadmap?._id
}`}
target="_blank"
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
<Shapes className="mr-1.5 h-4 w-4 stroke-[2.5]" />
<span className="hidden sm:inline-block">Edit Roadmap</span>
<span className="sm:hidden">Edit</span>
</a>
<button
onClick={() => setIsSharing(true)}
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
Sharing
</button>
<RoadmapActionButton
onDelete={() => {
const confirmation = window.confirm(
'Are you sure you want to delete this roadmap?'
);
if (!confirmation) {
return;
}
deleteResource().finally(() => null);
}}
/>
</>
)}
{!$canManageCurrentRoadmap && visibility === 'public' && (
<>
{sharingWithOthersModal}
<button
onClick={() => setIsSharingWithOthers(true)}
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
Share with Others
</button>
</>
)}
</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 flex-col justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
<div className="flex grow items-center gap-2">
{tabTypes.map((tab) => {
return (
<button
key={tab.value}
className={`relative flex w-full items-center justify-center whitespace-nowrap rounded-md border p-1 px-3 text-sm sm:w-auto ${
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';
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 roadmapId={roadmapId} canManage={roadmap.canManage} />
)}
</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,36 @@
export function SkeletonRoadmapHeader() {
return (
<div className="border-b">
<div className="container relative py-5 sm:py-12">
<div className="flex items-center gap-1.5">
<div className="h-4 w-4 animate-pulse rounded-full bg-gray-300" />
<div className="h-5 w-5/12 animate-pulse rounded-md bg-gray-200" />
</div>
<div className="mb-3 mt-4 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] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-32" />
<div className="flex items-center gap-2">
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139.71px]" />
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[100.34px]" />
<div className="h-7 w-[32px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[89.73px]" />
</div>
</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,13 @@ const { featuredItems, heading, allowBookmark = true } = Astro.props;
</li>
))
}
{
showCreateRoadmap && (
<li>
<CreateRoadmapButton client:load className='min-h-[54px]' />
</li>
)
}
</ul>
</div>
</div>

View File

@@ -31,7 +31,8 @@ import Icon from './AstroIcon.astro';
<a
class='px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'>YouTube</a>
target='_blank'>YouTube</a
>
</p>
<div class='flex flex-col justify-between gap-12 sm:flex-row'>
@@ -67,20 +68,30 @@ import Icon from './AstroIcon.astro';
<a href='/privacy' class='hover:text-white'>Privacy</a>
<span class='mx-1.5'>&middot;</span>
<a
aria-label="Subscribe to YouTube channel"
aria-label='Write us an email'
href='mailto:info@roadmap.sh'
class='hover:text-white'
>
<AstroIcon icon='letter' class='inline-block h-5 w-5' />
</a>
<a
aria-label='Subscribe to YouTube channel'
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'
class='hover:text-white'
class='ml-2 hover:text-white'
>
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
</a>
<a
aria-label="Follow on Twitter"
aria-label='Follow on Twitter'
href='https://twitter.com/roadmapsh'
target='_blank'
class='ml-2 hover:text-white'
>
<AstroIcon icon='twitter-fill' class='inline-block h-5 w-5 fill-current' />
<AstroIcon
icon='twitter-fill'
class='inline-block h-5 w-5 fill-current'
/>
</a>
</p>
</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

@@ -1,23 +1,30 @@
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { TeamAnnouncement } from '../TeamAnnouncement';
type EmptyProgressProps = {
title?: string;
message?: string;
title?: string;
message?: string;
};
export function EmptyProgress(props: EmptyProgressProps) {
const {
title = 'Start learning ..',
message = 'Your progress and favorite roadmaps will show up here.',
} = props;
const {
title = 'Start learning ..',
message = 'Your progress and favorite roadmaps will show up here.',
} = props;
return (
<div className="relative flex min-h-full flex-col items-start sm:items-center justify-center py-6">
<h2 className={'mb-1 flex items-center text-lg sm:text-2xl text-gray-200'}>
<CheckIcon additionalClasses='mr-2 top-[0.5px] w-[16px] h-[16px] sm:w-[20px] sm:h-[20px]' />
Start learning ..
</h2>
<p className={'text-gray-400 text-sm sm:text-base'}>{message}</p>
</div>
);
return (
<div className="relative flex min-h-full flex-col items-start justify-center py-6 sm:items-center">
<h2
className={'mb-1.5 flex items-center text-lg text-gray-200 sm:text-2xl'}
>
<CheckIcon additionalClasses="mr-2 top-[0.5px] w-[16px] h-[16px] sm:w-[20px] sm:h-[20px]" />
{title}
</h2>
<p className={'text-sm text-gray-400 sm:text-base'}>{message}</p>
<p className="mt-5">
<TeamAnnouncement />
</p>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { EmptyProgress } from './EmptyProgress';
import { httpGet } from '../../lib/http';
import { HeroRoadmaps } from './HeroRoadmaps';
import {isLoggedIn} from "../../lib/jwt";
import { HeroRoadmaps, type HeroTeamRoadmaps } from './HeroRoadmaps';
import { isLoggedIn } from '../../lib/jwt';
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
export type UserProgressResponse = {
resourceId: string;
@@ -14,6 +15,12 @@ export type UserProgressResponse = {
skipped: number;
total: number;
updatedAt: Date;
isCustomResource: boolean;
team?: {
name: string;
id: string;
role: AllowedMemberRoles;
};
}[];
function renderProgress(progressList: UserProgressResponse) {
@@ -48,6 +55,8 @@ function renderProgress(progressList: UserProgressResponse) {
});
}
type ProgressResponse = UserProgressResponse;
export function FavoriteRoadmaps() {
const isAuthenticated = isLoggedIn();
if (!isAuthenticated) {
@@ -56,7 +65,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 +88,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,19 +119,44 @@ export function FavoriteRoadmaps() {
return null;
}
const hasProgress = progress.length > 0;
const hasProgress = progress?.length > 0;
const customRoadmaps = progress?.filter(
(p) => p.isCustomResource && !p.team?.name
);
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
const teamRoadmaps: HeroTeamRoadmaps = progress
?.filter((p) => p.isCustomResource && p.team?.name)
.reduce((acc: HeroTeamRoadmaps, curr) => {
const currTeam = curr.team!;
if (!acc[currTeam.name]) {
acc[currTeam.name] = [];
}
acc[currTeam.name].push(curr);
return acc;
}, {});
return (
<div
className={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
hasProgress && `border-t border-t-[#1e293c]`
}`}
className={`transition-opacity duration-500 opacity-${containerOpacity}`}
>
<div className="container min-h-full">
{!isLoading && progress.length == 0 && <EmptyProgress />}
{progress.length > 0 && (
<HeroRoadmaps customRoadmaps={[]} progress={progress} isLoading={isLoading} />
)}
<div
className={`flex min-h-[192px] bg-gradient-to-b sm:min-h-[280px] ${
hasProgress && `border-t border-t-[#1e293c]`
}`}
>
<div className="container min-h-full">
{!isLoading && progress?.length == 0 && <EmptyProgress />}
{hasProgress && (
<HeroRoadmaps
teamRoadmaps={teamRoadmaps}
customRoadmaps={customRoadmaps}
progress={defaultRoadmaps}
isLoading={isLoading}
/>
)}
</div>
</div>
</div>
);

View File

@@ -3,7 +3,11 @@ import { CheckIcon } from '../ReactIcons/CheckIcon';
import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
import { Spinner } from '../ReactIcons/Spinner';
import type { ResourceType } from '../../lib/resource-progress';
import { MapIcon } from 'lucide-react';
import { MapIcon, Users2 } from 'lucide-react';
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { type ReactNode, useState } from 'react';
import { TeamAnnouncement } from '../TeamAnnouncement';
type ProgressRoadmapProps = {
url: string;
@@ -52,7 +56,7 @@ function HeroRoadmap(props: ProgressRoadmapProps) {
type ProgressTitleProps = {
icon: any;
isLoading?: boolean;
title: string;
title: string | ReactNode;
};
export function HeroTitle(props: ProgressTitleProps) {
@@ -70,24 +74,40 @@ export function HeroTitle(props: ProgressTitleProps) {
</p>
);
}
export type HeroTeamRoadmaps = Record<string, UserProgressResponse>;
type ProgressListProps = {
progress: UserProgressResponse;
showCustomRoadmaps?: boolean;
customRoadmaps: any[]; // @fixme implement this
customRoadmaps: UserProgressResponse;
teamRoadmaps?: HeroTeamRoadmaps;
isLoading?: boolean;
};
export function HeroRoadmaps(props: ProgressListProps) {
const {
teamRoadmaps = {},
progress,
isLoading = false,
customRoadmaps = [{} /* @fixme implement this */],
showCustomRoadmaps = false,
customRoadmaps,
} = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [creatingRoadmapTeamId, setCreatingRoadmapTeamId] = useState<string>();
return (
<div className="relative pb-12 pt-4 sm:pt-7">
<p className="mb-7 mt-2 text-sm">
<TeamAnnouncement />
</p>
{isCreatingRoadmap && (
<CreateRoadmapModal
teamId={creatingRoadmapTeamId}
onClose={() => {
setIsCreatingRoadmap(false);
setCreatingRoadmapTeamId(undefined);
}}
/>
)}
{
<HeroTitle
icon={
@@ -118,38 +138,127 @@ 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>
{Object.keys(teamRoadmaps).map((teamName) => {
const currentTeam: UserProgressResponse[0]['team'] =
teamRoadmaps?.[teamName]?.[0]?.team;
const roadmapsList = teamRoadmaps[teamName].filter(
(roadmap) => !!roadmap.resourceTitle
);
const canManageTeam = ['admin', 'manager'].includes(currentTeam?.role!);
return (
<div className="mt-5" key={teamName}>
{
<HeroTitle
icon={<Users2 className="mr-1.5 h-[14px] w-[14px]" />}
title={
<>
Team{' '}
<a
className="mx-1 font-medium underline underline-offset-2 transition-colors hover:text-gray-300"
href={`/team/progress?t=${currentTeam?.id}`}
>
{teamName}
</a>
Roadmaps
</>
}
/>
}
{roadmapsList.length === 0 && (
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
Team does not have any roadmaps yet.{' '}
{canManageTeam && (
<button
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
onClick={() => {
setCreatingRoadmapTeamId(currentTeam?.id);
setIsCreatingRoadmap(true);
}}
>
Create one!
</button>
)}
</p>
)}
{roadmapsList.length > 0 && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{roadmapsList.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}
/>
);
})}
{canManageTeam && (
<CreateRoadmapButton
teamId={currentTeam?.id}
text="Create Team Roadmap"
/>
)}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,12 +1,19 @@
---
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
import {TeamAnnouncement} from "../TeamAnnouncement";
---
<div class='relative min-h-auto min-h-[192px] sm:min-h-[281px] border-b border-b-[#1e293c]'>
<div
class='min-h-auto relative min-h-[192px] border-b border-b-[#1e293c] sm:min-h-[281px] transition-all'
>
<div
class='container px-6 py-6 pb-14 text-left sm:px-0 sm:py-20 sm:text-center transition-opacity duration-300'
class='container px-5 py-6 pb-14 text-left transition-opacity duration-300 sm:px-0 sm:py-20 sm:text-center'
id='hero-text'
>
<p class='-mt-4 sm:-mt-10 mb-7'>
<TeamAnnouncement />
</p>
<h1
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl'
>
@@ -24,5 +31,5 @@ import { FavoriteRoadmaps } from './FavoriteRoadmaps';
their career.
</p>
</div>
<FavoriteRoadmaps client:only="react" />
<FavoriteRoadmaps client:only='react' />
</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,68 @@
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';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
export function AccountDropdown() {
const dropdownRef = useRef(null);
const [showDropdown, setShowDropdown] = useState(false);
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
useOutsideClick(dropdownRef, () => {
setShowDropdown(false);
setIsTeamsOpen(false);
});
if (!isLoggedIn()) {
return null;
}
return (
<div className="relative z-50 animate-fade-in">
{isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => {
setIsCreatingRoadmap(false);
}}
/>
)}
<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
onCreateRoadmap={() => {
setIsCreatingRoadmap(true);
setShowDropdown(false);
}}
setIsTeamsOpen={setIsTeamsOpen}
/>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { ChevronRight, LogOut, Map, Plus, User2, Users2 } from 'lucide-react';
import { logout } from './navigation';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
import { useState } from 'react';
type AccountDropdownListProps = {
onCreateRoadmap: () => void;
setIsTeamsOpen: (isOpen: boolean) => void;
};
export function AccountDropdownList(props: AccountDropdownListProps) {
const { setIsTeamsOpen, onCreateRoadmap } = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
return (
<ul>
<li className="px-1">
<a
href="/account"
className="group flex items-center gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<User2 className="h-4 w-4 stroke-[2.5px] text-slate-400 group-hover:text-white" />
Profile
</a>
</li>
<li className="px-1">
<a
href="/account/friends"
className="group flex items-center gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Users2 className="h-4 w-4 stroke-[2px] text-slate-400 group-hover:text-white" />
Friends
</a>
</li>
<li className="mt-1 border-t border-t-gray-700/60 px-1 pt-1">
<button
onClick={() => {
onCreateRoadmap();
}}
className="group flex w-full items-center gap-2 rounded py-2 pl-3 pr-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Plus className="h-4 w-4 stroke-[2px] text-slate-400 group-hover:text-white" />
New Roadmap
</button>
</li>
<li className="border-b border-b-gray-700/60 px-1 pb-1">
<a
href="/account/roadmaps"
className="group flex items-center gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Map className="h-4 w-4 stroke-[2px] text-slate-400 group-hover:text-white" />
Roadmaps
</a>
</li>
<li className="px-1 pt-1">
<button
className="group flex w-full items-center justify-between rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
onClick={() => setIsTeamsOpen(true)}
>
<span className="flex items-center gap-2.5">
<Users2 className="h-4 w-4 stroke-[2px] text-slate-400 group-hover:text-white" />
Teams
</span>
<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="group flex gap-2 items-center w-full rounded py-2 pl-3 pr-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700"
type="button"
onClick={logout}
>
<LogOut className="h-4 w-4 stroke-[2px] text-slate-400 group-hover:text-white" />
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,8 +1,7 @@
---
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'>
<nav class='container flex items-center justify-between'>
<a
@@ -24,10 +23,28 @@ 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>
<li>
<a href='/teams' class='group relative text-blue-300 hover:text-white'>
Teams
<span
class='ml-0.5 hidden rounded-sm border-black bg-blue-300 px-1 py-0.5 text-xs font-semibold uppercase text-black group-hover:bg-white md:inline'
>
New
</span>
<span class='inline md:hidden absolute -right-[11px] top-0'>
<span class='relative flex h-2 w-2'>
<span
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
></span>
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
></span>
</span>
</span>
</a>
</li>
<li>
<kbd
@@ -44,7 +61,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 +125,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

@@ -105,7 +105,7 @@ export function PageSponsor(props: PageSponsorProps) {
</span>
<img
src={imageUrl}
className="block h-[150px] w-[104.89px] object-contain lg:h-[169px] lg:w-[118.18px]"
className="block h-[150px] object-fill lg:h-[169px] lg:w-[118.18px]"
alt="Sponsor Banner"
/>
<span className="flex flex-1 flex-col justify-between text-sm">

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,407 @@
import {
type ReactNode,
useCallback,
useState,
useMemo,
useEffect,
} 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 { ShareSuccess } from './ShareSuccess';
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: {
isDiscoverable: boolean;
visibility: AllowedRoadmapVisibility;
sharedTeamMemberIds: string[];
sharedFriendIds: string[];
}) => void;
type ShareOptionsModalProps = {
onClose: () => void;
visibility: AllowedRoadmapVisibility;
isDiscoverable?: boolean;
sharedFriendIds?: string[];
sharedTeamMemberIds?: string[];
teamId?: string;
roadmapId?: string;
description?: string;
onShareSettingsUpdate: OnShareSettingsUpdate;
};
export function ShareOptionsModal(props: ShareOptionsModalProps) {
const {
roadmapId,
onClose,
isDiscoverable: defaultIsDiscoverable = false,
visibility: defaultVisibility,
sharedTeamMemberIds: defaultSharedMemberIds = [],
sharedFriendIds: defaultSharedFriendIds = [],
teamId,
onShareSettingsUpdate,
description,
} = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
const [friends, setFriends] = useState<ListFriendsResponse>([]);
const [teams, setTeams] = useState<UserTeamItem[]>([]);
// Using global team members loading state to avoid glitchy UI when switching between teams
const [isTeamMembersLoading, setIsTeamMembersLoading] = useState(false);
const membersCache = useMemo(() => new Map<string, TeamMemberList[]>(), []);
const [visibility, setVisibility] = useState(defaultVisibility);
const [isDiscoverable, setIsDiscoverable] = useState(defaultIsDiscoverable);
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,
isDiscoverable,
}
);
if (error) {
toast.error(error?.message || 'Something went wrong, please try again');
return;
}
setIsLoading(false);
setIsSettingsUpdated(true);
onShareSettingsUpdate({
isDiscoverable,
sharedFriendIds,
visibility,
sharedTeamMemberIds,
});
};
const handleTransferToTeam = useCallback(
async (teamId: string, sharedTeamMemberIds: string[]) => {
if (!roadmapId) {
return;
}
setIsLoading(true);
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-transfer-roadmap/${roadmapId}`,
{
teamId,
sharedTeamMemberIds,
isDiscoverable,
}
);
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"
>
<ShareSuccess
visibility={visibility}
roadmapId={roadmapId!}
description={description}
onClose={onClose}
/>
</Modal>
);
}
return (
<Modal
onClose={() => {
if (isLoading) {
return;
}
onClose();
}}
wrapperClassName="max-w-3xl"
bodyClassName="p-4 flex flex-col min-h-[440px]"
>
<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([]);
}
setIsDiscoverable(visibility === 'public');
}}
/>
<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}
/>
)}
{/* For Team Roadmap */}
{visibility === 'team' && teamId && (
<ShareTeamMemberList
teamId={teamId}
sharedTeamMemberIds={sharedTeamMemberIds}
setSharedTeamMemberIds={setSharedTeamMemberIds}
membersCache={membersCache}
isTeamMembersLoading={isTeamMembersLoading}
setIsTeamMembersLoading={setIsTeamMembersLoading}
/>
)}
{canTransferRoadmap && (
<>
<TransferToTeamList
teams={teams}
setTeams={setTeams}
selectedTeamId={selectedTeamId}
setSelectedTeamId={setSelectedTeamId}
isTeamMembersLoading={isTeamMembersLoading}
setIsTeamMembersLoading={setIsTeamMembersLoading}
onTeamChange={() => {
setSharedTeamMemberIds([]);
}}
/>
{selectedTeamId && (
<>
<hr className="-mx-4 my-4" />
<div className="mb-4">
<ShareTeamMemberList
title="Select who can access this roadmap. You can change this later."
teamId={selectedTeamId!}
sharedTeamMemberIds={sharedTeamMemberIds}
setSharedTeamMemberIds={setSharedTeamMemberIds}
membersCache={membersCache}
isTeamMembersLoading={isTeamMembersLoading}
setIsTeamMembersLoading={setIsTeamMembersLoading}
/>
</div>
</>
)}
</>
)}
</div>
{visibility !== 'me' && (
<>
<hr className="-mx-4 my-4" />
<div className="mb-2">
<DiscoveryCheckbox
isDiscoverable={isDiscoverable}
setIsDiscoverable={setIsDiscoverable}
/>
</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 || sharedTeamMemberIds.length === 0
}
onClick={() => {
handleTransferToTeam(selectedTeamId!, sharedTeamMemberIds).then(
() => null
);
}}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Transfer
</UpdateAction>
)}
{!canTransferRoadmap && (
<UpdateAction
disabled={isUpdateDisabled || isLoading}
onClick={() => {
handleShareChange({
isDiscoverable,
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>
);
}
type DiscoveryCheckboxProps = {
isDiscoverable: boolean;
setIsDiscoverable: (isDiscoverable: boolean) => void;
};
function DiscoveryCheckbox(props: DiscoveryCheckboxProps) {
const { isDiscoverable, setIsDiscoverable } = props;
return (
<label className="group flex items-center gap-1.5">
<input
type="checkbox"
checked={isDiscoverable}
onChange={(e) => setIsDiscoverable(e.target.checked)}
/>
<span className="text-sm text-gray-500 group-hover:text-gray-700">
Include on discovery page (when launched)
</span>
</label>
);
}

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,132 @@
import { CheckCircle, Copy, Facebook, Linkedin, Twitter } from 'lucide-react';
import { useCopyText } from '../../hooks/use-copy-text';
import { cn } from '../../lib/classname';
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
type ShareSuccessProps = {
roadmapId: string;
onClose: () => void;
visibility: AllowedRoadmapVisibility;
description?: string;
isSharingWithOthers?: boolean;
};
export function ShareSuccess(props: ShareSuccessProps) {
const {
roadmapId,
onClose,
description,
visibility,
isSharingWithOthers = false,
} = props;
const baseUrl = import.meta.env.DEV
? 'http://localhost:3000'
: 'https://roadmap.sh';
const shareLink = `${baseUrl}/r?id=${roadmapId}`;
const { copyText, isCopied } = useCopyText();
const socialShareLinks = [
{
title: 'Twitter',
href: `https://twitter.com/intent/tweet?text=${description}&url=${shareLink}`,
icon: Twitter,
},
{
title: 'Facebook',
href: `https://www.facebook.com/sharer/sharer.php?quote=${description}&u=${shareLink}`,
icon: Facebook,
},
{
title: 'Linkedin',
href: `https://www.linkedin.com/sharing/share-offsite/?url=${shareLink}`,
icon: Linkedin,
},
];
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" />
{isSharingWithOthers ? (
<h3 className="text-xl font-medium">Sharing with Others</h3>
) : (
<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);
}}
/>
{isSharingWithOthers ? (
<p className="mt-1 text-sm text-gray-400">
You can share the above link with anyone
</p>
) : (
<p className="mt-1 text-sm text-gray-400">
You can share the above link with anyone who has access
</p>
)}
{visibility === 'public' && (
<>
<div className="-mx-4 mt-4 flex items-center gap-1.5">
<span className="h-px grow bg-gray-300" />
<span className="px-2 text-xs uppercase text-gray-400">Or</span>
<span className="h-px grow bg-gray-300" />
</div>
<div className="mt-4 flex items-center gap-2">
<span className="text-sm text-gray-600">Share with others on</span>
<ul className="flex items-center gap-1.5">
{socialShareLinks.map((socialShareLink) => (
<li key={socialShareLink.title}>
<a
href={socialShareLink.href}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 w-8 items-center justify-center gap-1.5 rounded-md border bg-gray-50 text-sm text-gray-700 hover:bg-gray-100 focus:outline-none"
>
<socialShareLink.icon className="h-4 w-4" />
</a>
</li>
))}
</ul>
</div>
</>
)}
<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 URL'}
</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,172 @@
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;
title?: string;
sharedTeamMemberIds: string[];
setSharedTeamMemberIds: (sharedTeamMemberIds: string[]) => void;
membersCache: Map<string, TeamMemberList[]>;
isTeamMembersLoading: boolean;
setIsTeamMembersLoading: (isLoading: boolean) => void;
};
export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
const {
teamId,
title = 'Select Members',
sharedTeamMemberIds,
setSharedTeamMemberIds,
membersCache,
isTeamMembersLoading: isLoading,
setIsTeamMembersLoading: setIsLoading,
} = props;
const toast = useToast();
async function loadTeamMembers() {
if (membersCache.has(teamId)) {
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;
}
const joinedMembers =
response?.filter((member) => member.status === 'joined') || [];
membersCache.set(teamId, joinedMembers);
}
useEffect(() => {
loadTeamMembers().finally(() => {
setIsLoading(false);
});
}, [teamId]);
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-[66px] 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>
);
const members = membersCache.get(teamId) || [];
return (
<>
{(members.length > 0 || isLoading) && (
<div className="flex items-center justify-between gap-2">
<p className="text-sm">{title}</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,132 @@
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;
isTeamMembersLoading: boolean;
setIsTeamMembersLoading: (isLoading: boolean) => void;
onTeamChange: (teamId: string | null) => void;
};
export function TransferToTeamList(props: TransferToTeamListProps) {
const {
teams,
setTeams,
selectedTeamId,
setSelectedTeamId,
isTeamMembersLoading,
setIsTeamMembersLoading,
onTeamChange,
} = 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 disabled:cursor-not-allowed disabled:opacity-70',
isSelected && 'border-gray-500 bg-gray-100 text-black'
)}
disabled={isTeamMembersLoading}
onClick={() => {
if (isSelected) {
setSelectedTeamId(null);
} else {
setSelectedTeamId(team._id);
}
onTeamChange(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

@@ -0,0 +1,16 @@
type TeamAnnouncementProps = {};
export function TeamAnnouncement(props: TeamAnnouncementProps) {
return (
<a
className="rounded-md border border-dashed border-purple-700 px-3 py-1.5 text-purple-400 transition-colors hover:border-gray-700 hover:text-white"
href="/teams"
>
<span className="relative -top-[0.5px] mr-1 text-xs font-semibold uppercase text-white">
New
</span>{' '}
<span className={'hidden sm:inline'}>Announcing roadmaps for teams. <span className='font-semibold'>Learn more!</span></span>
<span className={'inline sm:hidden'}>Announcing roadmaps for teams!</span>
</a>
);
}

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

@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import { cn } from '../../lib/classname.ts';
import { isLoggedIn } from '../../lib/jwt.ts';
import { fireTeamCreationClick } from './TeamHeroBanner.tsx';
const demoItems = [
{
title: 'Roadmap Editor',
description:
'<span class="font-semibold">Powerful editor</span> to create custom roadmaps and other trackable documents',
image: '/images/team-promo/roadmap-editor.png',
},
{
title: 'Invite Members',
description:
'Invite your <span class="font-semibold">team members and assign roles</span>',
image: '/images/team-promo/invite-members.png',
},
{
title: 'Track Progress',
description:
'You and your team can <span class="font-semibold">track progress</span> on the roadmaps',
image: '/images/team-promo/update-progress.png',
},
{
title: 'Team Dashboard',
description:
'Keep an eye on the team progress through <span class="font-semibold">team dashboards</span>',
image: '/images/team-promo/team-dashboard.png',
},
{
title: 'Roadmaps and Documents',
description:
'Create as many <span class="font-semibold">roadmaps or trackable documents</span> as you want',
image: '/images/team-promo/many-roadmaps.png',
},
{
title: 'Community Roadmaps',
description:
'Create custom roadmaps or customize <span class="font-semibold">community roadmaps</span> to fit your needs',
image: '/images/team-promo/our-roadmaps.png',
},
{
title: 'Sharing Settings',
description:
'Share a roadmap or trackable document with <span class="font-semibold">everyone or specific people</span>',
image: '/images/team-promo/sharing-settings.png',
},
{
title: 'More Coming Soon!',
description:
'<span class="font-semibold">We have a lot more coming soon!</span>',
},
];
export function TeamDemo() {
const [hasViewed, setHasViewed] = useState<number[]>([0]);
const [activeItem, setActiveItem] = useState(demoItems[0]);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
useEffect(() => {
setIsAuthenticated(isLoggedIn());
}, []);
return (
<div className="hidden border-t py-12 sm:block">
<div className="container">
<h2 className="mb-2 text-3xl font-bold">See how it works</h2>
<p>Here is a sneak peek of what you can do today (more coming soon!)</p>
<div className="relative mt-7 flex flex-row items-center gap-2.5">
{demoItems.map((item, counter) => {
const isActive = item === activeItem;
const hasAlreadyViewed = hasViewed.includes(counter);
if (!isActive) {
return (
<span key={item.title} className="relative flex items-center">
<span
onClick={() => {
setHasViewed([...hasViewed, counter]);
setActiveItem(item);
}}
className={cn('z-50 cursor-pointer rounded-full p-[6px]', {
'bg-black': item === activeItem,
'bg-gray-300 hover:bg-gray-400': item !== activeItem,
})}
/>
{!hasAlreadyViewed && (
<span className="pointer-events-none absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-400 opacity-75"></span>
)}
</span>
);
}
return (
<span
key={item.title}
className=" rounded-full bg-black px-3 py-0.5 text-sm text-white"
>
{activeItem.title}
</span>
);
})}
</div>
<div className="mt-4 overflow-hidden rounded-xl border border-gray-300">
<div className="p-3">
<p
className="text-base text-black"
dangerouslySetInnerHTML={{ __html: activeItem.description }}
/>
</div>
{activeItem.image && (
<img
className="rounded-b-xl border-t"
src={activeItem.image}
alt=""
/>
)}
{!activeItem.image && (
<div className="bg-gray-50 py-4 pl-3">
<p className="mb-3">
Register your team now and help us shape the future of teams in
roadmap.sh!
</p>
<div className="flex flex-row items-center gap-2">
<a
onClick={() => {
fireTeamCreationClick();
if (isAuthenticated) {
return;
}
localStorage.setItem('authRedirect', '/team/new');
}}
href={isAuthenticated ? '/team/new' : '/signup'}
className="inline-flex items-center justify-center rounded-lg border border-transparent bg-purple-600 px-5 py-2 text-base font-medium text-white hover:bg-purple-700"
>
Create your Team
</a>
{!isAuthenticated && (
<span className="ml-1 text-base">
or &nbsp;
<a
onClick={() => {
fireTeamCreationClick();
localStorage.setItem('authRedirect', '/team/new');
}}
href="/login"
className="text-purple-600 underline hover:text-purple-700"
>
Login to your account
</a>
</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { CheckCircle, CheckCircle2, CheckIcon } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt.ts';
import { useEffect, useState } from 'react';
const featureList = [
'Create custom roadmaps for your team',
"Plan, track and document your team's skills and growth",
'Invite your team members',
"Get insights on your team's skills and growth",
];
export function fireTeamCreationClick() {
window.fireEvent({
category: 'FeatureClick',
action: `Pages / Teams`,
label: 'Create your Team',
});
}
export function TeamHeroBanner() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
useEffect(() => {
setIsAuthenticated(isLoggedIn());
}, []);
return (
<div className="bg-white py-8 lg:py-12">
<div className="container">
<div className="flex flex-row items-center justify-start text-left lg:justify-between">
<div className="flex flex-grow flex-col">
<h1 className="mb-0.5 text-2xl font-bold sm:mb-2.5 sm:text-4xl lg:mb-4 lg:text-5xl">
Roadmaps for Teams
</h1>
<p className="mb-4 text-base leading-normal text-gray-600 sm:mb-0 sm:leading-none lg:text-lg">
Train, plan and track your team's skills and career growth.
</p>
<ul className="mb-4 mt-0 hidden text-sm leading-7 sm:mb-4 sm:mt-4 sm:flex sm:flex-col lg:mb-6 lg:mt-6 lg:text-base lg:leading-8">
{featureList.map((feature, index) => (
<li key={feature}>
<CheckCircle className="hidden h-6 w-6 text-green-500 sm:inline-block" />
<span className="ml-0 sm:ml-2">{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<a
onClick={() => {
fireTeamCreationClick();
}}
href={isAuthenticated ? '/team/new' : '/signup'}
className="flex w-full items-center justify-center rounded-lg border border-transparent bg-purple-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 sm:w-auto sm:text-base"
>
Create your Team
</a>
{!isAuthenticated && (
<>
<span className="ml-1 hidden text-base sm:inline">
or &nbsp;
<a
href="/login"
onClick={() => {
fireTeamCreationClick();
localStorage.setItem('authRedirect', '/team/new');
}}
className="text-purple-600 underline hover:text-purple-700"
>
Login to your account
</a>
</span>
<a
href="/login"
onClick={() => {
fireTeamCreationClick();
localStorage.setItem('authRedirect', '/team/new');
}}
className="flex w-full items-center justify-center rounded-lg border border-purple-600 px-5 py-2 text-base text-sm font-medium text-purple-600 hover:bg-blue-700 sm:hidden sm:text-base"
>
Login to your account
</a>
</>
)}
</div>
</div>
<img
alt={'team roadmaps'}
className="hidden h-64 md:block lg:h-80"
src="/images/team-promo/hero-img.png"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { Check, CheckCircle, Copy, Sparkles } from 'lucide-react';
import { useCopyText } from '../../hooks/use-copy-text.ts';
import { cn } from '../../lib/classname.ts';
import { isLoggedIn } from '../../lib/jwt.ts';
import { fireTeamCreationClick } from './TeamHeroBanner.tsx';
import { useEffect, useState } from 'react';
export function TeamPricing() {
const { isCopied, copyText } = useCopyText();
const teamEmail = 'teams@roadmap.sh';
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
useEffect(() => {
setIsAuthenticated(isLoggedIn());
}, []);
return (
<div className="border-t py-4 sm:py-8 md:py-12">
<div className="container">
<h2 className="mb-1 text-xl font-bold sm:mb-1.5 sm:text-2xl md:mb-2 md:text-3xl">
Beta Pricing
</h2>
<p className="mb-4 text-base text-gray-600 sm:mb-8 sm:text-lg">
We are currently in public beta and are offering free access to all
features.
</p>
<div className="flex flex-col gap-6 sm:flex-row sm:gap-4">
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-purple-500">
<div className="px-8 pb-2 pt-5 text-center sm:pt-4">
<h3 className="mb-1 text-2xl font-bold">Free</h3>
<p className="text-sm text-gray-500">No credit card required</p>
<p className="flex items-start justify-center gap-1 py-6 text-3xl">
<span className="text-base text-gray-600">$</span>
<span className="text-5xl font-bold">0</span>
</p>
<a
onClick={() => {
fireTeamCreationClick();
if (isAuthenticated) {
return;
}
localStorage.setItem('redirect', '/team/new');
}}
href={isAuthenticated ? '/team/new' : '/signup'}
className="block rounded-md bg-purple-600 px-6 py-2 text-center text-sm font-medium leading-6 text-white shadow transition hover:bg-gray-700 hover:shadow-lg focus:outline-none"
>
{isAuthenticated ? 'Create your Team' : 'Sign up for free'}
</a>
</div>
<div className="flex w-full flex-col gap-1 border-t px-8 py-5 text-center sm:py-3">
<p className="font-semibold text-black">All the features</p>
<p className="text-gray-600">Roles and Permissions</p>
<p className="text-gray-600">Custom Roadmaps</p>
<p className="text-gray-600">Sharing Options</p>
<p className="text-gray-600">Progress Tracking</p>
<p className="text-gray-600">Team Insights</p>
<p className="text-gray-600">Onboarding support</p>
</div>
</div>
<div className="flex flex-grow flex-col items-center justify-center rounded-md border border-gray-300 py-8">
<img
alt={'waving hand'}
src={'/images/team-promo/contact.png'}
className="mb-3 h-40"
/>
<p className="mb-2 font-medium text-gray-500">
Questions? We are here to help!
</p>
<p className="text-gray-600">
<button
onClick={() => {
copyText(teamEmail);
}}
className={cn(
'relative flex items-center justify-between gap-3 overflow-hidden rounded-md border border-black bg-white px-4 py-2 text-black hover:bg-gray-100'
)}
>
{teamEmail}
<Copy
className="relative top-[1px] ml-2 inline-block text-black transition-opacity"
size={16}
/>
<span
className={cn(
'absolute bottom-0 left-0 right-0 flex items-center justify-center bg-black text-white transition-all',
{
'top-full': !isCopied,
'top-0': isCopied,
}
)}
>
Email copied!
</span>
</button>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
const toolsList = [
{
imageUrl: '/images/team-promo/growth-plans.png',
title: 'Growth plans',
description: 'Prepare shared or individual growth plans for members.',
},
{
imageUrl: '/images/team-promo/progress-tracking.png',
title: 'Progress tracking',
description: 'Track and compare the progress of team members.',
},
{
imageUrl: '/images/team-promo/onboarding.png',
title: 'Onboarding',
description: 'Prepare onboarding plans for new team members.',
},
{
imageUrl: '/images/team-promo/team-insights.png',
title: 'Team insights',
description: 'Get insights about your team skills, progress and more.',
},
{
imageUrl: '/images/team-promo/skill-gap.png',
title: 'Skill gap analysis',
description: 'Understand the skills of your team and identify gaps.',
},
{
imageUrl: '/images/team-promo/documentation.png',
title: 'Documentation',
description: 'Create and share visual team documentation.',
},
];
export function TeamTools() {
return (
<div className="py-4 sm:py-8 md:py-12 border-t">
<div className="container">
<h2 className="mb-1 sm:mb-1.5 md:mb-2 text-xl sm:text-2xl md:text-3xl font-bold">Track and guide your teams knowledge</h2>
<p className='text-sm md:text-base'>
Individual and team level growth plans, progress tracking, skill gap analysis, team insights and more.
</p>
<div className="mt-3 sm:mt-5 md:mt-8 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 sm:gap-4">
{toolsList.map((tool) => {
return (
<div className="rounded-md sm:rounded-xl border p-2 sm:p-5 text-left sm:text-center md:text-left">
<img
alt={tool.title}
src={tool.imageUrl}
className="mb-5 h-48 hidden sm:block mx-auto md:mx-0"
/>
<h3 className="mb-0.5 sm:mb-2 text-lg sm:text-2xl font-bold">{tool.title}</h3>
<p className='text-sm sm:text-base'>{tool.description}</p>
</div>
);
})}
</div>
</div>
</div>
);
}

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

@@ -44,6 +44,8 @@ export interface TeamMemberItem extends TeamMemberDocument {
hasProgress: boolean;
}
const MAX_MEMBER_COUNT = 100;
export function TeamMembersPage() {
const { t: teamId } = getUrlParams();
@@ -307,7 +309,7 @@ export function TeamMembersPage() {
{canManageCurrentTeam && (
<div className="mt-4">
<button
disabled={teamMembers.length >= 25}
disabled={teamMembers.length >= MAX_MEMBER_COUNT}
onClick={() => setIsInvitingMember(true)}
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"
>
@@ -316,7 +318,7 @@ export function TeamMembersPage() {
</div>
)}
{teamMembers.length >= 25 && canManageCurrentTeam && (
{teamMembers.length >= MAX_MEMBER_COUNT && canManageCurrentTeam && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
You have reached the maximum number of members in a team. Please reach
out to us if you need more.

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

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