mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-01-16 13:51:23 +01:00
Allow creating custom roadmaps (#4486)
* wip: custom roadmap renderer * wip: custom roadmap events * wip: roadmap content * wip: svg styles * wip: custom roadmap progress * Render progress * Shortcut progress * Progress Tracking styles * wip: edit and share button * fix: disabled the share button * wip: content links rendering * Fix progress share * Replace disabled with `canShare` * wip: show custom roadmaps * wip: users all roadmaps * fix: create roadmap api * chore: roadmap sidebar icon * wip: content links * Update links color * Create roadmap home * Create Roadmap button * Roadmap type * chore: share progress modal * wip: share roadmap * wip: change visibility * chore: custom roadmap progress in activity * wip: custom roadmap share progress * chore: friend's roadmap * wip: custom roadmap skeleton * chore: roadmap title * Restricted Page * fix: skeleton loading width * Fix create roadmap button * chore: remove user id * chore: pick roadmap and share * chore: open new tab on create roadmap * chore: change share title * chore: use team id from params * chore: team roadmap create modal * chore: create team roadmap * chore: custom roadmap modal * chore: placeholde roadmaps * chore: roadmap hint * chore: visibility label * chore: public roadmap * chore: empty screen * chore: team progress * chore: create roadmap responsive * chore: form error * chore: multi user history * wip: manage custom roadmap * chore: empty roadmap list * chore: custom roadmap visit * chore: shared roadmaps * chore: shared roadmaps * chore: empty screen and topic title * chore: show progress bar * Implement Error in topic details * Add Modal close button * fix: link groups * Refactor roadmap creation * Refactor roadmap creation * Refactor team creation * Refactor team roadmaps * Refactor team creation roadmap selection * Refactor * Refactor team roadmap loading * Refactor team roadmaps * Refactor team roadmaps listing * Refactor Account dropdown * Updates * Refactor Account dropdown * Fix Team name overflow * Change Icon color * Update team dropdown * Minor UI fixes * Fix minor UI * Flicker fix in team dropdown * Roadmap action dropdown with responsiveness * Team roadmaps listing * Update team settings * Team roadmaps listing * fix: remove visibility change * Update roadmap options modal * Add dummy renderer * Add renderer script * Add generate renderer script * Add generate renderer * wip: add share settings * Update * Update UI * Update Minor UI * Fix team issue * Update Personal roadmaps UI * Add Roadmap Secret * Update teams type * Rearrange sections * Change Secret name * Add action button on roadmap detail page --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
parent
d45c8f9cb2
commit
8310671123
@ -1,2 +1,3 @@
|
||||
PUBLIC_API_URL=http://api.roadmap.sh
|
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
||||
PUBLIC_EDITOR_APP_URL=
|
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@ -27,6 +27,7 @@ jobs:
|
||||
pnpm install
|
||||
- name: Generate meta and build
|
||||
run: |
|
||||
npm run generate-renderer
|
||||
npm run build
|
||||
touch ./dist/.nojekyll
|
||||
echo 'roadmap.sh' > ./dist/CNAME
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,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
|
@ -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
431
pnpm-lock.yaml
generated
@ -32,6 +32,9 @@ dependencies:
|
||||
astro-compress:
|
||||
specifier: ^2.0.8
|
||||
version: 2.0.8
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
dracula-prism:
|
||||
specifier: ^2.1.13
|
||||
version: 2.1.13
|
||||
@ -44,6 +47,9 @@ dependencies:
|
||||
lucide-react:
|
||||
specifier: ^0.274.0
|
||||
version: 0.274.0(react@18.0.0)
|
||||
nanoid:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
nanostores:
|
||||
specifier: ^0.9.2
|
||||
version: 0.9.2
|
||||
@ -65,6 +71,9 @@ dependencies:
|
||||
react-dom:
|
||||
specifier: ^18.0.0
|
||||
version: 18.0.0(react@18.0.0)
|
||||
reactflow:
|
||||
specifier: ^11.8.3
|
||||
version: 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
rehype-external-links:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
@ -74,6 +83,9 @@ dependencies:
|
||||
slugify:
|
||||
specifier: ^1.6.6
|
||||
version: 1.6.6
|
||||
tailwind-merge:
|
||||
specifier: ^1.14.0
|
||||
version: 1.14.0
|
||||
tailwindcss:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
@ -1056,6 +1068,114 @@ packages:
|
||||
config-chain: 1.1.13
|
||||
dev: false
|
||||
|
||||
/@reactflow/background@11.2.8(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
|
||||
resolution: {integrity: sha512-5o41N2LygiNC2/Pk8Ak2rIJjXbKHfQ23/Y9LFsnAlufqwdzFqKA8txExpsMoPVHHlbAdA/xpQaMuoChGPqmyDw==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
classcat: 5.0.4
|
||||
react: 18.0.0
|
||||
react-dom: 18.0.0(react@18.0.0)
|
||||
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@reactflow/controls@11.1.19(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
|
||||
resolution: {integrity: sha512-Vo0LFfAYjiSRMLEII/aeBo+1MT2a0Yc7iLVnkuRTLzChC0EX+A2Fa+JlzeOEYKxXlN4qcDxckRNGR7092v1HOQ==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
classcat: 5.0.4
|
||||
react: 18.0.0
|
||||
react-dom: 18.0.0(react@18.0.0)
|
||||
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@reactflow/core@11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
|
||||
resolution: {integrity: sha512-y6DN8Wy4V4KQBGHFqlj9zWRjLJU6CgdnVwWaEA/PdDg/YUkFBMpZnXqTs60czinoA2rAcvsz50syLTPsj5e+Wg==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@types/d3': 7.4.0
|
||||
'@types/d3-drag': 3.0.3
|
||||
'@types/d3-selection': 3.0.6
|
||||
'@types/d3-zoom': 3.0.4
|
||||
classcat: 5.0.4
|
||||
d3-drag: 3.0.0
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
react: 18.0.0
|
||||
react-dom: 18.0.0(react@18.0.0)
|
||||
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@reactflow/minimap@11.6.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
|
||||
resolution: {integrity: sha512-PSA28dk09RnBHOA1zb45fjQXz3UozSJZmsIpgq49O3trfVFlSgRapxNdGsughWLs7/emg2M5jmi6Vc+ejcfjvQ==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
'@types/d3-selection': 3.0.6
|
||||
'@types/d3-zoom': 3.0.4
|
||||
classcat: 5.0.4
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
react: 18.0.0
|
||||
react-dom: 18.0.0(react@18.0.0)
|
||||
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@reactflow/node-resizer@2.1.5(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
|
||||
resolution: {integrity: sha512-z/hJlsptd2vTx13wKouqvN/Kln08qbkA+YTJLohc2aJ6rx3oGn9yX4E4IqNxhA7zNqYEdrnc1JTEA//ifh9z3w==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
classcat: 5.0.4
|
||||
d3-drag: 3.0.0
|
||||
d3-selection: 3.0.0
|
||||
react: 18.0.0
|
||||
react-dom: 18.0.0(react@18.0.0)
|
||||
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@reactflow/node-toolbar@1.2.7(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
|
||||
resolution: {integrity: sha512-vs+Wg1tjy3SuD7eoeTqEtscBfE9RY+APqC28urVvftkrtsN7KlnoQjqDG6aE45jWP4z+8bvFizRWjAhxysNLkg==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
classcat: 5.0.4
|
||||
react: 18.0.0
|
||||
react-dom: 18.0.0(react@18.0.0)
|
||||
zustand: 4.4.1(@types/react@18.0.21)(react@18.0.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@sigstore/bundle@1.1.0:
|
||||
resolution: {integrity: sha512-PFutXEy0SmQxYI4texPw3dd2KewuNqv7OuK1ZFtY2fM754yhvG2KdgwIhRnoEE2uHdtdGNQ8s0lb94dW9sELog==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@ -1175,6 +1295,185 @@ packages:
|
||||
'@types/css-tree': 2.3.1
|
||||
dev: false
|
||||
|
||||
/@types/d3-array@3.0.7:
|
||||
resolution: {integrity: sha512-4/Q0FckQ8TBjsB0VdGFemJOG8BLXUB2KKlL0VmZ+eOYeOnTb/wDRQqYWpBmQ6IlvWkXwkYiot+n9Px2aTJ7zGQ==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-axis@3.0.3:
|
||||
resolution: {integrity: sha512-SE3x/pLO/+GIHH17mvs1uUVPkZ3bHquGzvZpPAh4yadRy71J93MJBpgK/xY8l9gT28yTN1g9v3HfGSFeBMmwZw==}
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.6
|
||||
dev: false
|
||||
|
||||
/@types/d3-brush@3.0.3:
|
||||
resolution: {integrity: sha512-MQ1/M/B5ifTScHSe5koNkhxn2mhUPqXjGuKjjVYckplAPjP9t2I2sZafb/YVHDwhoXWZoSav+Q726eIbN3qprA==}
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.6
|
||||
dev: false
|
||||
|
||||
/@types/d3-chord@3.0.3:
|
||||
resolution: {integrity: sha512-keuSRwO02c7PBV3JMWuctIfdeJrVFI7RpzouehvBWL4/GGUB3PBNg/9ZKPZAgJphzmS2v2+7vr7BGDQw1CAulw==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-contour@3.0.3:
|
||||
resolution: {integrity: sha512-x7G/tdDZt4m09XZnG2SutbIuQqmkNYqR9uhDMdPlpJbcwepkEjEWG29euFcgVA1k6cn92CHdDL9Z+fOnxnbVQw==}
|
||||
dependencies:
|
||||
'@types/d3-array': 3.0.7
|
||||
'@types/geojson': 7946.0.10
|
||||
dev: false
|
||||
|
||||
/@types/d3-delaunay@6.0.1:
|
||||
resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-dispatch@3.0.3:
|
||||
resolution: {integrity: sha512-Df7KW3Re7G6cIpIhQtqHin8yUxUHYAqiE41ffopbmU5+FifYUNV7RVyTg8rQdkEagg83m14QtS8InvNb95Zqug==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-drag@3.0.3:
|
||||
resolution: {integrity: sha512-82AuQMpBQjuXeIX4tjCYfWjpm3g7aGCfx6dFlxX2JlRaiME/QWcHzBsINl7gbHCODA2anPYlL31/Trj/UnjK9A==}
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.6
|
||||
dev: false
|
||||
|
||||
/@types/d3-dsv@3.0.2:
|
||||
resolution: {integrity: sha512-DooW5AOkj4AGmseVvbwHvwM/Ltu0Ks0WrhG6r5FG9riHT5oUUTHz6xHsHqJSVU8ZmPkOqlUEY2obS5C9oCIi2g==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-ease@3.0.0:
|
||||
resolution: {integrity: sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-fetch@3.0.3:
|
||||
resolution: {integrity: sha512-/EsDKRiQkby3Z/8/AiZq8bsuLDo/tYHnNIZkUpSeEHWV7fHUl6QFBjvMPbhkKGk9jZutzfOkGygCV7eR/MkcXA==}
|
||||
dependencies:
|
||||
'@types/d3-dsv': 3.0.2
|
||||
dev: false
|
||||
|
||||
/@types/d3-force@3.0.5:
|
||||
resolution: {integrity: sha512-EGG+IWx93ESSXBwfh/5uPuR9Hp8M6o6qEGU7bBQslxCvrdUBQZha/EFpu/VMdLU4B0y4Oe4h175nSm7p9uqFug==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-format@3.0.1:
|
||||
resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-geo@3.0.4:
|
||||
resolution: {integrity: sha512-kmUK8rVVIBPKJ1/v36bk2aSgwRj2N/ZkjDT+FkMT5pgedZoPlyhaG62J+9EgNIgUXE6IIL0b7bkLxCzhE6U4VQ==}
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.10
|
||||
dev: false
|
||||
|
||||
/@types/d3-hierarchy@3.1.3:
|
||||
resolution: {integrity: sha512-GpSK308Xj+HeLvogfEc7QsCOcIxkDwLhFYnOoohosEzOqv7/agxwvJER1v/kTC+CY1nfazR0F7gnHo7GE41/fw==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==}
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.0
|
||||
dev: false
|
||||
|
||||
/@types/d3-path@3.0.0:
|
||||
resolution: {integrity: sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-polygon@3.0.0:
|
||||
resolution: {integrity: sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-quadtree@3.0.2:
|
||||
resolution: {integrity: sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-random@3.0.1:
|
||||
resolution: {integrity: sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-scale-chromatic@3.0.0:
|
||||
resolution: {integrity: sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-scale@4.0.4:
|
||||
resolution: {integrity: sha512-eq1ZeTj0yr72L8MQk6N6heP603ubnywSDRfNpi5enouR112HzGLS6RIvExCzZTraFF4HdzNpJMwA/zGiMoHUUw==}
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.0
|
||||
dev: false
|
||||
|
||||
/@types/d3-selection@3.0.6:
|
||||
resolution: {integrity: sha512-2ACr96USZVjXR9KMD9IWi1Epo4rSDKnUtYn6q2SPhYxykvXTw9vR77lkFNruXVg4i1tzQtBxeDMx0oNvJWbF1w==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-shape@3.1.2:
|
||||
resolution: {integrity: sha512-NN4CXr3qeOUNyK5WasVUV8NCSAx/CRVcwcb0BuuS1PiTqwIm6ABi1SyasLZ/vsVCFDArF+W4QiGzSry1eKYQ7w==}
|
||||
dependencies:
|
||||
'@types/d3-path': 3.0.0
|
||||
dev: false
|
||||
|
||||
/@types/d3-time-format@4.0.0:
|
||||
resolution: {integrity: sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-time@3.0.0:
|
||||
resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-timer@3.0.0:
|
||||
resolution: {integrity: sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-transition@3.0.4:
|
||||
resolution: {integrity: sha512-512a4uCOjUzsebydItSXsHrPeQblCVk8IKjqCUmrlvBWkkVh3donTTxmURDo1YPwIVDh5YVwCAO6gR4sgimCPQ==}
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.6
|
||||
dev: false
|
||||
|
||||
/@types/d3-zoom@3.0.4:
|
||||
resolution: {integrity: sha512-cqkuY1ah9ZQre2POqjSLcM8g40UVya/qwEUrNYP2/rCVljbmqKCVcv+ebvwhlI5azIbSEL7m+os6n+WlYA43aA==}
|
||||
dependencies:
|
||||
'@types/d3-interpolate': 3.0.1
|
||||
'@types/d3-selection': 3.0.6
|
||||
dev: false
|
||||
|
||||
/@types/d3@7.4.0:
|
||||
resolution: {integrity: sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==}
|
||||
dependencies:
|
||||
'@types/d3-array': 3.0.7
|
||||
'@types/d3-axis': 3.0.3
|
||||
'@types/d3-brush': 3.0.3
|
||||
'@types/d3-chord': 3.0.3
|
||||
'@types/d3-color': 3.1.0
|
||||
'@types/d3-contour': 3.0.3
|
||||
'@types/d3-delaunay': 6.0.1
|
||||
'@types/d3-dispatch': 3.0.3
|
||||
'@types/d3-drag': 3.0.3
|
||||
'@types/d3-dsv': 3.0.2
|
||||
'@types/d3-ease': 3.0.0
|
||||
'@types/d3-fetch': 3.0.3
|
||||
'@types/d3-force': 3.0.5
|
||||
'@types/d3-format': 3.0.1
|
||||
'@types/d3-geo': 3.0.4
|
||||
'@types/d3-hierarchy': 3.1.3
|
||||
'@types/d3-interpolate': 3.0.1
|
||||
'@types/d3-path': 3.0.0
|
||||
'@types/d3-polygon': 3.0.0
|
||||
'@types/d3-quadtree': 3.0.2
|
||||
'@types/d3-random': 3.0.1
|
||||
'@types/d3-scale': 4.0.4
|
||||
'@types/d3-scale-chromatic': 3.0.0
|
||||
'@types/d3-selection': 3.0.6
|
||||
'@types/d3-shape': 3.1.2
|
||||
'@types/d3-time': 3.0.0
|
||||
'@types/d3-time-format': 4.0.0
|
||||
'@types/d3-timer': 3.0.0
|
||||
'@types/d3-transition': 3.0.4
|
||||
'@types/d3-zoom': 3.0.4
|
||||
dev: false
|
||||
|
||||
/@types/debug@4.1.8:
|
||||
resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==}
|
||||
dependencies:
|
||||
@ -1185,6 +1484,10 @@ packages:
|
||||
resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==}
|
||||
dev: false
|
||||
|
||||
/@types/geojson@7946.0.10:
|
||||
resolution: {integrity: sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==}
|
||||
dev: false
|
||||
|
||||
/@types/hast@2.3.5:
|
||||
resolution: {integrity: sha512-SvQi0L/lNpThgPoleH53cdjB3y9zpLlVjRbqB3rH8hx1jiRSBGAhyjV3H+URFjNVRqt2EdYNrbZE5IsGlNfpRg==}
|
||||
dependencies:
|
||||
@ -1766,6 +2069,10 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
dev: false
|
||||
|
||||
/classcat@5.0.4:
|
||||
resolution: {integrity: sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g==}
|
||||
dev: false
|
||||
|
||||
/clean-css@5.3.2:
|
||||
resolution: {integrity: sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==}
|
||||
engines: {node: '>= 10.0'}
|
||||
@ -1991,6 +2298,71 @@ packages:
|
||||
minimist: 1.2.8
|
||||
dev: true
|
||||
|
||||
/d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
dev: false
|
||||
|
||||
/d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
dev: false
|
||||
|
||||
/d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
dev: false
|
||||
|
||||
/d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
dev: false
|
||||
|
||||
/debug@4.3.4:
|
||||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
@ -2839,6 +3211,7 @@ packages:
|
||||
/iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
dev: false
|
||||
@ -3887,6 +4260,12 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
/nanoid@4.0.2:
|
||||
resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==}
|
||||
engines: {node: ^14 || ^16 || >=18}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/nanostores@0.9.2:
|
||||
resolution: {integrity: sha512-wfKlqLGtOYV9+qzGveqDOSWZUBgTeMr/g+JzfV/GofXQ//0wp0cgHF+QBVlmNH/JW9YA9QN+vR6N0vpniPpARA==}
|
||||
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
|
||||
@ -4670,6 +5049,25 @@ packages:
|
||||
loose-envify: 1.4.0
|
||||
dev: false
|
||||
|
||||
/reactflow@11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0):
|
||||
resolution: {integrity: sha512-wuVxJOFqi1vhA4WAEJLK0JWx2TsTiWpxTXTRp/wvpqKInQgQcB49I2QNyNYsKJCQ6jjXektS7H+LXoaVK/pG4A==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/background': 11.2.8(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
'@reactflow/controls': 11.1.19(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
'@reactflow/core': 11.8.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
'@reactflow/minimap': 11.6.3(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
'@reactflow/node-resizer': 2.1.5(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
'@reactflow/node-toolbar': 1.2.7(@types/react@18.0.21)(react-dom@18.0.0)(react@18.0.0)
|
||||
react: 18.0.0
|
||||
react-dom: 18.0.0(react@18.0.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/read-cache@1.0.0:
|
||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||
dependencies:
|
||||
@ -4942,6 +5340,7 @@ packages:
|
||||
|
||||
/safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
@ -5354,6 +5753,10 @@ packages:
|
||||
picocolors: 1.0.0
|
||||
dev: false
|
||||
|
||||
/tailwind-merge@1.14.0:
|
||||
resolution: {integrity: sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==}
|
||||
dev: false
|
||||
|
||||
/tailwindcss@3.3.3:
|
||||
resolution: {integrity: sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@ -5683,6 +6086,14 @@ packages:
|
||||
xdg-basedir: 5.1.0
|
||||
dev: false
|
||||
|
||||
/use-sync-external-store@1.2.0(react@18.0.0):
|
||||
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
react: 18.0.0
|
||||
dev: false
|
||||
|
||||
/util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@ -5912,6 +6323,26 @@ packages:
|
||||
resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==}
|
||||
dev: false
|
||||
|
||||
/zustand@4.4.1(@types/react@18.0.21)(react@18.0.0):
|
||||
resolution: {integrity: sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8'
|
||||
immer: '>=9.0'
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.0.21
|
||||
react: 18.0.0
|
||||
use-sync-external-store: 1.2.0(react@18.0.0)
|
||||
dev: false
|
||||
|
||||
/zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
dev: false
|
||||
|
14
renderer/index.tsx
Normal file
14
renderer/index.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
export function Renderer(props: any) {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 top-0 z-[9999] border bg-white p-5 text-black">
|
||||
<h2 className="mb-2 text-xl font-semibold">Private Component</h2>
|
||||
<p className="mb-4">
|
||||
Renderer is a private component. If you are a collaborator and have
|
||||
access to it. Run the following command:
|
||||
</p>
|
||||
<code className="mt-5 rounded-md bg-gray-800 p-2 text-white">
|
||||
npm run generate-renderer
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
5
renderer/renderer.ts
Normal file
5
renderer/renderer.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function renderFlowJSON(data: any, options?: any) {
|
||||
console.warn("renderFlowJSON is not implemented");
|
||||
console.warn("run the following command to generate the renderer:");
|
||||
console.warn("> npm run generate-renderer");
|
||||
}
|
31
scripts/generate-renderer.sh
Normal file
31
scripts/generate-renderer.sh
Normal file
@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
rm -rf .temp
|
||||
git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw
|
||||
|
||||
rm -rf renderer
|
||||
mkdir renderer
|
||||
|
||||
# copy the files at /src/editor/renderer/* to /renderer
|
||||
# while replacing any existing files
|
||||
cp -rf .temp/web-draw/src/editor/renderer/* renderer
|
||||
|
||||
# Add @ts-nocheck to the top of each ts and tsx file
|
||||
# so that the typescript compiler doesn't complain
|
||||
# about the missing types
|
||||
find renderer -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= read -r -d '' file; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "// @ts-nocheck" > temp
|
||||
cat "$file" >> temp
|
||||
mv temp "$file"
|
||||
echo "Added @ts-nocheck to $file"
|
||||
fi
|
||||
done
|
||||
|
||||
# remove the temporary directory
|
||||
rm -rf .temp
|
||||
|
||||
# ignore the worktree changes for the renderer directory
|
||||
git update-index --skip-worktree renderer/*
|
@ -2,6 +2,7 @@
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import { 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' />
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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';
|
||||
|
@ -36,6 +36,7 @@ function handleGuest() {
|
||||
'/account/notification',
|
||||
'/account/update-password',
|
||||
'/account/settings',
|
||||
'/account/roadmaps',
|
||||
'/account/road-card',
|
||||
'/account/friends',
|
||||
'/account',
|
||||
|
@ -1,37 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
|
||||
import { SelectRoadmapModal } from './SelectRoadmapModal';
|
||||
import { NotDropdown } from './NotDropdown';
|
||||
import { Map, Shapes } from 'lucide-react';
|
||||
import type {
|
||||
AllowedRoadmapVisibility,
|
||||
RoadmapDocument,
|
||||
} from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export type TeamResourceConfig = {
|
||||
isCustomResource: boolean;
|
||||
title: string;
|
||||
visibility?: AllowedRoadmapVisibility;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
removed: string[];
|
||||
topics?: number;
|
||||
sharedTeamMemberIds: string[];
|
||||
sharedFriendIds: string[];
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
teamId: string;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
teamResources: TeamResourceConfig;
|
||||
setTeamResources: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
const { teamId, teamResourceConfig = [], setTeamResourceConfig } = props;
|
||||
const { teamId, teamResources = [], setTeamResources } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
|
||||
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState<boolean>(false);
|
||||
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
async function loadAllRoadmaps() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Something went wrong. Please try again!');
|
||||
setError(error.message || 'Something went wrong. Please try again!');
|
||||
return;
|
||||
}
|
||||
@ -72,7 +87,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
setTeamResources(response);
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
@ -106,13 +121,25 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
setTeamResources(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAllRoadmaps().finally();
|
||||
loadAllRoadmaps().finally(() => {});
|
||||
}, []);
|
||||
|
||||
function handleCustomRoadmapCreated(roadmap: RoadmapDocument) {
|
||||
const { _id: roadmapId } = roadmap;
|
||||
if (!roadmapId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadAllRoadmaps().finally(() => {});
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{changingRoadmapId && (
|
||||
@ -121,9 +148,9 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={teamId}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResources}
|
||||
defaultRemovedItems={
|
||||
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
|
||||
teamResources.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
@ -131,7 +158,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
{showSelectRoadmapModal && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setShowSelectRoadmapModal(false)}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
teamResourceConfig={teamResources}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId) => {
|
||||
@ -145,72 +172,170 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<NotDropdown
|
||||
<div className="my-3 flex items-center gap-4">
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
teamId={teamId}
|
||||
onClose={() => setIsCreatingRoadmap(false)}
|
||||
onCreated={(roadmap: RoadmapDocument) => {
|
||||
handleCustomRoadmapCreated(roadmap);
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex h-10 grow items-center justify-center gap-2 rounded-md border border-black bg-white text-black transition-colors hover:bg-black hover:text-white"
|
||||
onClick={() => {
|
||||
setShowSelectRoadmapModal(true);
|
||||
}}
|
||||
selectedCount={teamResourceConfig.length}
|
||||
singularName={'roadmap'}
|
||||
pluralName={'roadmaps'}
|
||||
/>
|
||||
>
|
||||
<Map className="h-4 w-4 stroke-[2.5]" />
|
||||
Pick from our roadmaps
|
||||
</button>
|
||||
|
||||
<span className="text-base text-gray-400">or</span>
|
||||
|
||||
<button
|
||||
className="flex h-10 grow items-center justify-center gap-2 rounded-md border border-black bg-white text-black transition-colors hover:bg-black hover:text-white"
|
||||
onClick={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
}}
|
||||
>
|
||||
<Shapes className="h-4 w-4 stroke-[2.5]" />
|
||||
Create Custom Roadmap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!teamResourceConfig.length && (
|
||||
<p className={'mb-3 mt-2 text-base text-gray-400'}>
|
||||
No roadmaps selected.
|
||||
</p>
|
||||
{!teamResources.length && (
|
||||
<div className="flex min-h-[240px] flex-col items-center justify-center rounded-lg border">
|
||||
<Map className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className={'text-lg font-semibold'}>No roadmaps selected.</p>
|
||||
<p className={'text-base text-gray-400'}>
|
||||
Pick from{' '}
|
||||
<span
|
||||
onClick={() => setShowSelectRoadmapModal(true)}
|
||||
className="cursor-pointer underline"
|
||||
>
|
||||
our roadmaps
|
||||
</span>{' '}
|
||||
or{' '}
|
||||
<span
|
||||
onClick={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
}}
|
||||
className="cursor-pointer underline"
|
||||
>
|
||||
create a new one
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teamResourceConfig.length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 flex-wrap gap-2.5">
|
||||
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
|
||||
const roadmapTitle =
|
||||
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
|
||||
'...';
|
||||
{teamResources.length > 0 && (
|
||||
<div className="mb-3 grid grid-cols-1 flex-wrap gap-2.5 sm:grid-cols-3">
|
||||
{teamResources.map(
|
||||
({
|
||||
isCustomResource,
|
||||
title: roadmapTitle,
|
||||
resourceId,
|
||||
removed: removedTopics,
|
||||
topics,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col items-start overflow-hidden rounded-md border border-gray-300"
|
||||
key={resourceId}
|
||||
>
|
||||
<div className={'w-full flex-grow px-3 pb-2 pt-4'}>
|
||||
<span className="mb-0.5 block text-base font-medium leading-snug text-black">
|
||||
{roadmapTitle}
|
||||
</span>
|
||||
{removedTopics.length > 0 || (topics && topics > 0) ? (
|
||||
<span className={'text-xs leading-none text-gray-400'}>
|
||||
{isCustomResource ? (
|
||||
<>
|
||||
Custom · {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>
|
||||
|
@ -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={() => {
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -0,0 +1,53 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../../lib/jwt';
|
||||
import { showLoginPopup } from '../../../lib/popup';
|
||||
import { cn } from '../../../lib/classname';
|
||||
import {
|
||||
type AllowedCustomRoadmapType,
|
||||
type AllowedRoadmapVisibility,
|
||||
CreateRoadmapModal,
|
||||
} from './CreateRoadmapModal';
|
||||
import { useState } from 'react';
|
||||
|
||||
type CreateRoadmapButtonProps = {
|
||||
className?: string;
|
||||
type?: AllowedCustomRoadmapType;
|
||||
};
|
||||
|
||||
export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
|
||||
const { className, type } = props;
|
||||
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
function toggleCreateRoadmapHandler() {
|
||||
if (!isLoggedIn()) {
|
||||
return showLoginPopup();
|
||||
}
|
||||
|
||||
setIsCreatingRoadmap(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
type={type}
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center gap-1 overflow-hidden rounded-md border border-dashed border-gray-800 p-3 text-sm text-gray-400 hover:border-gray-600 hover:bg-gray-900 hover:text-gray-300',
|
||||
className
|
||||
)}
|
||||
onClick={toggleCreateRoadmapHandler}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create a new roadmap
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,275 @@
|
||||
import {
|
||||
type FormEvent,
|
||||
type MouseEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Modal } from '../../Modal';
|
||||
import { useToast } from '../../../hooks/use-toast';
|
||||
import { httpPost } from '../../../lib/http';
|
||||
import { cn } from '../../../lib/classname';
|
||||
import { allowedVisibilityLabels } from '../ShareRoadmapModal';
|
||||
|
||||
export const allowedRoadmapVisibility = [
|
||||
'me',
|
||||
'friends',
|
||||
'team',
|
||||
'public',
|
||||
] as const;
|
||||
export type AllowedRoadmapVisibility =
|
||||
(typeof allowedRoadmapVisibility)[number];
|
||||
export const allowedCustomRoadmapType = ['role', 'skill'] as const;
|
||||
export type AllowedCustomRoadmapType =
|
||||
(typeof allowedCustomRoadmapType)[number];
|
||||
|
||||
export interface RoadmapDocument {
|
||||
_id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
creatorId: string;
|
||||
teamId?: string;
|
||||
type: AllowedCustomRoadmapType;
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
sharedFriendIds?: string[];
|
||||
sharedTeamMemberIds?: string[];
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
canManage: boolean;
|
||||
isCustomResource: boolean;
|
||||
}
|
||||
|
||||
interface CreateRoadmapModalProps {
|
||||
onClose: () => void;
|
||||
onCreated?: (roadmap: RoadmapDocument) => void;
|
||||
teamId?: string;
|
||||
type?: AllowedCustomRoadmapType;
|
||||
visibility?: AllowedRoadmapVisibility;
|
||||
}
|
||||
|
||||
export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
const { onClose, onCreated, teamId, type: defaultType = 'role' } = props;
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [type, setType] = useState<AllowedCustomRoadmapType>(defaultType);
|
||||
const isInvalidDescription = description?.trim().length > 80;
|
||||
|
||||
async function handleSubmit(
|
||||
e: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>,
|
||||
redirect: boolean = true
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (title.trim() === '' || isInvalidDescription || !type) {
|
||||
toast.error('Please fill all the fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpPost<RoadmapDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-create-roadmap`,
|
||||
{
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
...(teamId && {
|
||||
teamId,
|
||||
}),
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Roadmap created successfully');
|
||||
if (redirect) {
|
||||
window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
|
||||
response?._id
|
||||
}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreated) {
|
||||
onCreated(response as RoadmapDocument);
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setType('role');
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
bodyClassName="p-4"
|
||||
wrapperClassName={cn(teamId && 'max-w-lg')}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Create Roadmap</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Add a title and description to your roadmap.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="title"
|
||||
className="block text-xs uppercase text-gray-400"
|
||||
>
|
||||
Roadmap Title
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
ref={titleRef}
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
required
|
||||
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
|
||||
placeholder="Enter Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-xs uppercase text-gray-400"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
required
|
||||
className={cn(
|
||||
'block h-24 w-full resize-none rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm',
|
||||
isInvalidDescription && 'border-red-300 bg-red-100'
|
||||
)}
|
||||
placeholder="Enter Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
|
||||
{description.length}/80
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="type"
|
||||
className="block text-xs uppercase text-gray-400"
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
required
|
||||
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
|
||||
value={type}
|
||||
onChange={(e) =>
|
||||
setType(e.target.value as AllowedCustomRoadmapType)
|
||||
}
|
||||
>
|
||||
{allowedCustomRoadmapType.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)} Based Roadmap
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn('mt-4 flex justify-between gap-2', teamId && 'mt-8')}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className={cn(
|
||||
'block h-9 rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-gray-300 focus:bg-gray-100',
|
||||
!teamId && 'w-full'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<div className={cn('flex items-center gap-2', !teamId && 'w-full')}>
|
||||
{teamId && !isLoading && (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={(e) => handleSubmit(e, false)}
|
||||
className="flex h-9 items-center justify-center rounded-md border border-black bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:bg-black hover:text-white focus:bg-black focus:text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
'Save as Placeholder'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
className={cn(
|
||||
'flex h-9 items-center justify-center rounded-md border border-transparent bg-black px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800',
|
||||
teamId ? 'hidden sm:flex' : 'w-full'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : teamId ? (
|
||||
'Continue to Editor'
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{teamId && (
|
||||
<>
|
||||
<p className="mt-4 hidden rounded-md border border-orange-200 bg-orange-50 p-2.5 text-sm text-orange-600 sm:block">
|
||||
Preparing the roadmap might take some time, feel free to save it
|
||||
as a placeholder and anyone with the role <strong>admin</strong>{' '}
|
||||
or <strong>manager</strong> can prepare it later.
|
||||
</p>
|
||||
<p className="mt-4 rounded-md border border-orange-200 bg-orange-50 p-2.5 text-sm text-orange-600 sm:hidden">
|
||||
Create a placeholder now and prepare it later.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
121
src/components/CustomRoadmap/CustomRoadmap.tsx
Normal file
121
src/components/CustomRoadmap/CustomRoadmap.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import {
|
||||
type AppError,
|
||||
type FetchError,
|
||||
httpGet,
|
||||
httpPost,
|
||||
} from '../../lib/http';
|
||||
import { RoadmapHeader } from './RoadmapHeader';
|
||||
import { RoadmapRenderer } from './RoadmapRenderer';
|
||||
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { currentRoadmap } from '../../stores/roadmap';
|
||||
import { UserProgressModal } from '../UserProgress/UserProgressModal';
|
||||
import { RestrictedPage } from './RestrictedPage';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
export const allowedLinkTypes = [
|
||||
'video',
|
||||
'article',
|
||||
'opensource',
|
||||
'course',
|
||||
'website',
|
||||
'podcast',
|
||||
] as const;
|
||||
|
||||
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
|
||||
|
||||
export interface RoadmapContentDocument {
|
||||
_id?: string;
|
||||
roadmapId: string;
|
||||
nodeId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
links: {
|
||||
id: string;
|
||||
type: AllowedLinkTypes;
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function hideRoadmapLoader() {
|
||||
const loaderEl = document.querySelector(
|
||||
'[data-roadmap-loader]'
|
||||
) as HTMLElement;
|
||||
if (loaderEl) {
|
||||
loaderEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomRoadmap() {
|
||||
const { id, secret } = getUrlParams() as { id: string; secret: string };
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [roadmap, setRoadmap] = useState<RoadmapDocument | null>(null);
|
||||
const [error, setError] = useState<AppError | FetchError | undefined>();
|
||||
|
||||
async function getRoadmap() {
|
||||
setIsLoading(true);
|
||||
|
||||
const roadmapUrl = new URL(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`
|
||||
);
|
||||
if (secret) {
|
||||
roadmapUrl.searchParams.set('secret', secret);
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<RoadmapDocument>(
|
||||
roadmapUrl.toString()
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = `${response.title} - roadmap.sh`;
|
||||
|
||||
setRoadmap(response);
|
||||
currentRoadmap.set(response);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function trackVisit() {
|
||||
if (!isLoggedIn()) return;
|
||||
await httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
|
||||
resourceId: id,
|
||||
resourceType: 'roadmap',
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getRoadmap().finally(() => {
|
||||
hideRoadmapLoader();
|
||||
});
|
||||
trackVisit().then();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <RestrictedPage error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoadmapHeader />
|
||||
<RoadmapRenderer roadmap={roadmap!} />
|
||||
<TopicDetail canSubmitContribution={false} />
|
||||
<UserProgressModal
|
||||
resourceId={roadmap?._id!}
|
||||
resourceType="roadmap"
|
||||
isCustomResource={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
12
src/components/CustomRoadmap/EmptyRoadmap.tsx
Normal file
12
src/components/CustomRoadmap/EmptyRoadmap.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { CircleSlash } from 'lucide-react';
|
||||
|
||||
export function EmptyRoadmap() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<CircleSlash className="mx-auto h-20 w-20 text-gray-400" />
|
||||
<h3 className="mt-4">This roadmap is currently empty.</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
import MoreIcon from '../../icons/more-vertical.svg';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
|
||||
|
||||
type PersonalRoadmapActionDropdownProps = {
|
||||
onDelete?: () => void;
|
||||
onCustomize?: () => void;
|
||||
onUpdateSharing?: () => void;
|
||||
};
|
||||
|
||||
export function PersonalRoadmapActionDropdown(props: PersonalRoadmapActionDropdownProps) {
|
||||
const { onDelete, onUpdateSharing, onCustomize } = props;
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useOutsideClick(menuRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
disabled={false}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex"
|
||||
>
|
||||
<img alt="menu" src={MoreIcon.src} className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={false}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none sm:hidden"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
Options
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="align-right absolute right-auto top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md sm:right-0"
|
||||
>
|
||||
<ul>
|
||||
{onUpdateSharing && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onUpdateSharing();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Lock size={14} className="mr-2" />
|
||||
Sharing
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onCustomize && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onCustomize();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Shapes size={14} className="mr-2" />
|
||||
Customize
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onDelete && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onDelete();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
238
src/components/CustomRoadmap/PersonalRoadmapList.tsx
Normal file
238
src/components/CustomRoadmap/PersonalRoadmapList.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { httpDelete } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import {
|
||||
ExternalLink,
|
||||
Shapes,
|
||||
type LucideIcon,
|
||||
Globe,
|
||||
LockIcon,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import {
|
||||
type AllowedRoadmapVisibility,
|
||||
type RoadmapDocument,
|
||||
} from './CreateRoadmap/CreateRoadmapModal';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import { PersonalRoadmapActionDropdown } from './PersonalRoadmapActionDropdown';
|
||||
import type { GetRoadmapListResponse } from './RoadmapListPage';
|
||||
import { useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
|
||||
|
||||
type PersonalRoadmapListType = {
|
||||
roadmaps: GetRoadmapListResponse['personalRoadmaps'];
|
||||
onDelete: (roadmapId: string) => void;
|
||||
setAllRoadmaps: Dispatch<SetStateAction<GetRoadmapListResponse>>;
|
||||
};
|
||||
|
||||
export function PersonalRoadmapList(props: PersonalRoadmapListType) {
|
||||
const { roadmaps: roadmapList, onDelete, setAllRoadmaps } = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [selectedRoadmap, setSelectedRoadmap] = useState<
|
||||
GetRoadmapListResponse['personalRoadmaps'][number] | null
|
||||
>(null);
|
||||
|
||||
async function deleteRoadmap(roadmapId: string) {
|
||||
const { response, error } = await httpDelete<RoadmapDocument[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-roadmap/${roadmapId}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
console.error(error);
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Roadmap deleted');
|
||||
onDelete(roadmapId);
|
||||
}
|
||||
|
||||
async function onRemove(roadmapId: string) {
|
||||
pageProgressMessage.set('Deleting roadmap');
|
||||
|
||||
deleteRoadmap(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
const shareSettingsModal = selectedRoadmap && (
|
||||
<ShareOptionsModal
|
||||
visibility={selectedRoadmap.visibility}
|
||||
sharedFriendIds={selectedRoadmap.sharedFriendIds}
|
||||
sharedTeamMemberIds={selectedRoadmap.sharedTeamMemberIds}
|
||||
roadmapId={selectedRoadmap._id!}
|
||||
onClose={() => setSelectedRoadmap(null)}
|
||||
onShareSettingsUpdate={(settings) => {
|
||||
setAllRoadmaps((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
personalRoadmaps: prev.personalRoadmaps.map((roadmap) => {
|
||||
if (roadmap._id === selectedRoadmap._id) {
|
||||
return {
|
||||
...roadmap,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
return roadmap;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (roadmapList.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 py-20">
|
||||
<img
|
||||
alt="roadmap"
|
||||
src={RoadmapIcon.src}
|
||||
className="mb-4 h-24 w-24 opacity-10"
|
||||
/>
|
||||
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
|
||||
<p className="text-base text-gray-500">
|
||||
Create a roadmap to get started
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{shareSettingsModal}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className={'text-sm text-gray-400'}>
|
||||
{roadmapList.length} custom roadmap(s)
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-col divide-y rounded-md border">
|
||||
{roadmapList.map((roadmap) => {
|
||||
return (
|
||||
<CustomRoadmapItem
|
||||
key={roadmap._id!}
|
||||
roadmap={roadmap}
|
||||
onRemove={onRemove}
|
||||
setSelectedRoadmap={setSelectedRoadmap}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CustomRoadmapItemProps = {
|
||||
roadmap: GetRoadmapListResponse['personalRoadmaps'][number];
|
||||
onRemove: (roadmapId: string) => Promise<void>;
|
||||
setSelectedRoadmap: (
|
||||
roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null
|
||||
) => void;
|
||||
};
|
||||
|
||||
function CustomRoadmapItem(props: CustomRoadmapItemProps) {
|
||||
const { roadmap, onRemove, setSelectedRoadmap } = props;
|
||||
|
||||
const editorLink = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmap._id}`;
|
||||
|
||||
return (
|
||||
<li
|
||||
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_110px]"
|
||||
key={roadmap._id!}
|
||||
>
|
||||
<div className="mb-3 grid grid-cols-1 sm:mb-0">
|
||||
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
|
||||
{roadmap.title}
|
||||
</p>
|
||||
<span className="flex items-center text-xs leading-none text-gray-400">
|
||||
<VisibilityBadge
|
||||
visibility={roadmap.visibility!}
|
||||
sharedFriendIds={roadmap.sharedFriendIds}
|
||||
/>
|
||||
<span className="mx-2 font-semibold">·</span>
|
||||
<Shapes size={16} className="mr-1 inline-block h-4 w-4" />
|
||||
{roadmap.topics} topic
|
||||
</span>
|
||||
</div>
|
||||
<div className="mr-1 flex items-center justify-start sm:justify-end">
|
||||
<PersonalRoadmapActionDropdown
|
||||
onUpdateSharing={() => {
|
||||
setSelectedRoadmap(roadmap);
|
||||
}}
|
||||
onCustomize={() => {
|
||||
window.open(editorLink, '_blank');
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (confirm('Are you sure you want to remove this roadmap?')) {
|
||||
onRemove(roadmap._id!).finally(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<a
|
||||
href={`/r?id=${roadmap._id}`}
|
||||
className={
|
||||
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
|
||||
}
|
||||
target={'_blank'}
|
||||
>
|
||||
<ExternalLink className="inline-block h-4 w-4" />
|
||||
Visit
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type VisibilityLabelProps = {
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
sharedFriendIds?: string[];
|
||||
};
|
||||
|
||||
const visibilityDetails: Record<
|
||||
AllowedRoadmapVisibility,
|
||||
{
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
> = {
|
||||
public: {
|
||||
icon: Globe,
|
||||
label: 'Public',
|
||||
},
|
||||
me: {
|
||||
icon: LockIcon,
|
||||
label: 'Only me',
|
||||
},
|
||||
team: {
|
||||
icon: Users,
|
||||
label: 'Team Member(s)',
|
||||
},
|
||||
friends: {
|
||||
icon: Users,
|
||||
label: 'Friend(s)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function VisibilityBadge(props: VisibilityLabelProps) {
|
||||
const { visibility, sharedFriendIds = [] } = props;
|
||||
|
||||
const { label, icon: Icon } = visibilityDetails[visibility];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`}
|
||||
>
|
||||
<Icon className="inline-block h-3 w-3" />
|
||||
<div className="flex items-center">
|
||||
{visibility === 'friends' && sharedFriendIds?.length > 0 && (
|
||||
<span className="mr-1">{sharedFriendIds.length}</span>
|
||||
)}
|
||||
{label}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
111
src/components/CustomRoadmap/ResourceProgressStats.tsx
Normal file
111
src/components/CustomRoadmap/ResourceProgressStats.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { canManageCurrentRoadmap, currentRoadmap } from '../../stores/roadmap';
|
||||
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
|
||||
|
||||
type ResourceProgressStatsProps = {
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
isSecondaryBanner?: boolean;
|
||||
};
|
||||
|
||||
export function ResourceProgressStats(props: ResourceProgressStatsProps) {
|
||||
const { isSecondaryBanner = false } = props;
|
||||
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
|
||||
const $currentRoadmap = useStore(currentRoadmap);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSharing && $canManageCurrentRoadmap && $currentRoadmap && (
|
||||
<ShareOptionsModal
|
||||
visibility={$currentRoadmap?.visibility}
|
||||
teamId={$currentRoadmap?.teamId}
|
||||
roadmapId={$currentRoadmap?._id!}
|
||||
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
|
||||
sharedTeamMemberIds={$currentRoadmap?.sharedTeamMemberIds || []}
|
||||
onClose={() => setIsSharing(false)}
|
||||
onShareSettingsUpdate={(settings) => {
|
||||
currentRoadmap.set({
|
||||
...$currentRoadmap,
|
||||
...settings,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
data-progress-nums-container=""
|
||||
className={cn(
|
||||
'striped-loader relative hidden items-center justify-between bg-white px-2 py-1.5 sm:flex',
|
||||
{
|
||||
'rounded-bl-md rounded-br-md': isSecondaryBanner,
|
||||
'rounded-md': !isSecondaryBanner,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className="flex text-sm opacity-0 transition-opacity duration-300"
|
||||
data-progress-nums=""
|
||||
>
|
||||
<span className="mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
|
||||
<span data-progress-percentage="">0</span>% Done
|
||||
</span>
|
||||
|
||||
<span className="itesm-center hidden md:flex">
|
||||
<span>
|
||||
<span data-progress-done="">0</span> completed
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-learning="">0</span> in progress
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-skipped="">0</span> skipped
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-total="">0</span> Total
|
||||
</span>
|
||||
</span>
|
||||
<span className="md:hidden">
|
||||
<span data-progress-done="">0</span> of{' '}
|
||||
<span data-progress-total="">0</span> Done
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-3 opacity-0 transition-opacity duration-300"
|
||||
data-progress-nums=""
|
||||
>
|
||||
<button
|
||||
data-popup="progress-help"
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black"
|
||||
data-progress-nums=""
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5 stroke-[2.5px]" />
|
||||
Track Progress
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-progress-nums-container=""
|
||||
className="striped-loader relative -mb-2 flex items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden"
|
||||
>
|
||||
<span
|
||||
data-progress-nums=""
|
||||
className="text-gray-500 opacity-0 transition-opacity duration-300"
|
||||
>
|
||||
<span data-progress-done="">0</span> of{' '}
|
||||
<span data-progress-total="">0</span> Done
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
52
src/components/CustomRoadmap/RestrictedPage.tsx
Normal file
52
src/components/CustomRoadmap/RestrictedPage.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { ShieldBan } from 'lucide-react';
|
||||
import type { FetchError } from '../../lib/http';
|
||||
|
||||
type RestrictedPageProps = {
|
||||
error: FetchError;
|
||||
};
|
||||
|
||||
export function RestrictedPage(props: RestrictedPageProps) {
|
||||
const { error } = props;
|
||||
|
||||
if (error.status === 404) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
icon={<ShieldBan className="h-16 w-16" />}
|
||||
title="Roadmap not found"
|
||||
message="The roadmap you are looking for does not exist or has been deleted."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorMessage
|
||||
icon={<ShieldBan className="h-16 w-16" />}
|
||||
title="Restricted Access"
|
||||
message={error?.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ErrorMessageProps = {
|
||||
title: string;
|
||||
message: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
function ErrorMessage(props: ErrorMessageProps) {
|
||||
const { title, message, icon } = props;
|
||||
return (
|
||||
<div className="flex grow flex-col items-center justify-center">
|
||||
{icon}
|
||||
<h2 className="mt-4 text-2xl font-semibold">{title}</h2>
|
||||
<p>{message || 'This roadmap is not available for public access.'}</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
className="mt-4 font-medium underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
← Go back to home
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
85
src/components/CustomRoadmap/RoadmapActionButton.tsx
Normal file
85
src/components/CustomRoadmap/RoadmapActionButton.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
|
||||
|
||||
type RoadmapActionButtonProps = {
|
||||
onDelete?: () => void;
|
||||
onCustomize?: () => void;
|
||||
onUpdateSharing?: () => void;
|
||||
};
|
||||
|
||||
export function RoadmapActionButton(props: RoadmapActionButtonProps) {
|
||||
const { onDelete, onUpdateSharing, onCustomize } = props;
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useOutsideClick(menuRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
disabled={false}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="inline-flex items-center justify-center rounded-md bg-gray-500 py-1.5 pl-2 pr-2 text-xs font-medium text-white hover:bg-gray-600 sm:pl-1.5 sm:pr-3 sm:text-sm"
|
||||
>
|
||||
<MoreVertical className="mr-0 h-4 w-4 stroke-[2.5] sm:mr-1.5" />
|
||||
<span className="hidden sm:inline">Actions</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="align-right absolute right-0 top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
|
||||
>
|
||||
<ul>
|
||||
{onUpdateSharing && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onUpdateSharing();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Lock size={14} className="mr-2" />
|
||||
Sharing
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onCustomize && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onCustomize();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Shapes size={14} className="mr-2" />
|
||||
Customize
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onDelete && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onDelete();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
142
src/components/CustomRoadmap/RoadmapHeader.tsx
Normal file
142
src/components/CustomRoadmap/RoadmapHeader.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { RoadmapHint } from './RoadmapHint';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { canManageCurrentRoadmap, currentRoadmap } from '../../stores/roadmap';
|
||||
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
|
||||
import { useState } from 'react';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { httpDelete, httpPut } from '../../lib/http';
|
||||
import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { RoadmapActionButton } from './RoadmapActionButton';
|
||||
|
||||
type RoadmapHeaderProps = {};
|
||||
|
||||
export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
|
||||
const $currentRoadmap = useStore(currentRoadmap);
|
||||
|
||||
const { title, description, _id: roadmapId } = useStore(currentRoadmap) || {};
|
||||
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
async function deleteResource() {
|
||||
pageProgressMessage.set('Deleting roadmap');
|
||||
|
||||
const teamId = $currentRoadmap?.teamId;
|
||||
const baseApiUrl = import.meta.env.PUBLIC_API_URL;
|
||||
|
||||
let error, response;
|
||||
if (teamId) {
|
||||
({ error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${baseApiUrl}/v1-delete-team-resource-config/${teamId}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
}
|
||||
));
|
||||
} else {
|
||||
({ error, response } = await httpDelete<TeamResourceConfig>(
|
||||
`${baseApiUrl}/v1-delete-roadmap/${roadmapId}`
|
||||
));
|
||||
}
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Roadmap removed');
|
||||
if (!teamId) {
|
||||
window.location.href = '/account/roadmaps';
|
||||
} else {
|
||||
window.location.href = `/team/roadmaps?t=${teamId}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container relative py-5 sm:py-12">
|
||||
<div className="mb-3 mt-0 sm:mb-4">
|
||||
<h1 className="text-2xl font-bold sm:mb-2 sm:text-4xl">{title}</h1>
|
||||
<p className="mt-0.5 text-sm text-gray-500 sm:text-lg">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 sm:gap-0">
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
aria-label="Back to All Roadmaps"
|
||||
>
|
||||
←<span className="hidden sm:inline"> All Roadmaps</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
data-guest-required
|
||||
data-popup="login-popup"
|
||||
className="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
|
||||
aria-label="Subscribe for Updates"
|
||||
>
|
||||
<span className="ml-2">Subscribe</span>
|
||||
</button>
|
||||
</div>
|
||||
{$canManageCurrentRoadmap && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isSharing && $currentRoadmap && (
|
||||
<ShareOptionsModal
|
||||
visibility={$currentRoadmap?.visibility}
|
||||
teamId={$currentRoadmap?.teamId}
|
||||
roadmapId={$currentRoadmap?._id!}
|
||||
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
|
||||
sharedTeamMemberIds={
|
||||
$currentRoadmap?.sharedTeamMemberIds || []
|
||||
}
|
||||
onClose={() => setIsSharing(false)}
|
||||
onShareSettingsUpdate={(settings) => {
|
||||
currentRoadmap.set({
|
||||
...$currentRoadmap,
|
||||
...settings,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoadmapActionButton
|
||||
onDelete={() => {
|
||||
const confirmation = window.confirm(
|
||||
'Are you sure you want to delete this roadmap?'
|
||||
);
|
||||
|
||||
if (!confirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteResource().finally(() => null);
|
||||
}}
|
||||
onCustomize={() => {
|
||||
const editorLink = `${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${$currentRoadmap?._id}`;
|
||||
|
||||
window.open(editorLink, '_blank');
|
||||
}}
|
||||
onUpdateSharing={() => {
|
||||
setIsSharing(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RoadmapHint
|
||||
roadmapTitle={title!}
|
||||
hasTNSBanner={false}
|
||||
roadmapId={roadmapId!}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
48
src/components/CustomRoadmap/RoadmapHint.tsx
Normal file
48
src/components/CustomRoadmap/RoadmapHint.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
import { ResourceProgressStats } from './ResourceProgressStats';
|
||||
|
||||
type RoadmapHintProps = {
|
||||
roadmapId: string;
|
||||
roadmapTitle: string;
|
||||
hasTNSBanner?: boolean;
|
||||
tnsBannerLink?: string;
|
||||
};
|
||||
|
||||
export function RoadmapHint(props: RoadmapHintProps) {
|
||||
const {
|
||||
roadmapTitle,
|
||||
roadmapId,
|
||||
hasTNSBanner = false,
|
||||
tnsBannerLink = '',
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('mb-0 mt-4 rounded-md border-0 sm:mt-7 sm:border', {
|
||||
'sm:-mb-[82px]': hasTNSBanner,
|
||||
'sm:-mb-[65px]': !hasTNSBanner,
|
||||
})}
|
||||
>
|
||||
{hasTNSBanner && (
|
||||
<div className="hidden border-b bg-gray-100 px-2 py-1.5 sm:block">
|
||||
<p className="text-sm">
|
||||
Get the latest {roadmapTitle} news from our sister site{' '}
|
||||
<a
|
||||
href={tnsBannerLink}
|
||||
target="_blank"
|
||||
className="font-semibold underline"
|
||||
>
|
||||
TheNewStack.io
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResourceProgressStats
|
||||
isSecondaryBanner={hasTNSBanner}
|
||||
resourceId={roadmapId}
|
||||
resourceType="roadmap"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
134
src/components/CustomRoadmap/RoadmapListPage.tsx
Normal file
134
src/components/CustomRoadmap/RoadmapListPage.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import {
|
||||
CreateRoadmapModal,
|
||||
type RoadmapDocument,
|
||||
} from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { PersonalRoadmapList } from './PersonalRoadmapList';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { SharedRoadmapList } from './SharedRoadmapList';
|
||||
import type { FriendshipStatus } from '../Befriend';
|
||||
|
||||
export type FriendUserType = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
status: FriendshipStatus;
|
||||
};
|
||||
|
||||
export type GetRoadmapListResponse = {
|
||||
personalRoadmaps: (RoadmapDocument & {
|
||||
topics: number;
|
||||
})[];
|
||||
sharedRoadmaps: (RoadmapDocument & {
|
||||
topics: number;
|
||||
creator: FriendUserType;
|
||||
})[];
|
||||
};
|
||||
|
||||
type TabType = {
|
||||
label: string;
|
||||
value: 'personal' | 'shared';
|
||||
};
|
||||
|
||||
const tabTypes: TabType[] = [
|
||||
{ label: 'Personal', value: 'personal' },
|
||||
{ label: 'Shared by Friends', value: 'shared' },
|
||||
];
|
||||
|
||||
export function RoadmapListPage() {
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType['value']>('personal');
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<GetRoadmapListResponse>({
|
||||
personalRoadmaps: [],
|
||||
sharedRoadmaps: [],
|
||||
});
|
||||
|
||||
async function loadRoadmapList() {
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpGet<GetRoadmapListResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-roadmap-list`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
console.error(error);
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
setAllRoadmaps(
|
||||
response! || {
|
||||
personalRoadmaps: [],
|
||||
sharedRoadmaps: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRoadmapList().finally(() => {
|
||||
setIsLoading(false);
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{tabTypes.map((tab) => {
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
className={`relative flex items-center justify-center rounded-md border p-1 px-3 text-sm ${
|
||||
activeTab === tab.value ? ' border-gray-400 bg-gray-200 ' : ''
|
||||
} w-full sm:w-auto`}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className={`relative flex w-full items-center justify-center rounded-md border p-1 px-3 text-sm sm:w-auto`}
|
||||
onClick={() => setIsCreatingRoadmap(true)}
|
||||
>
|
||||
+ Create Roadmap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{activeTab === 'personal' && (
|
||||
<PersonalRoadmapList
|
||||
roadmaps={allRoadmaps?.personalRoadmaps}
|
||||
setAllRoadmaps={setAllRoadmaps}
|
||||
onDelete={(roadmapId) => {
|
||||
setAllRoadmaps({
|
||||
...allRoadmaps,
|
||||
personalRoadmaps: allRoadmaps.personalRoadmaps.filter(
|
||||
(r) => r._id !== roadmapId
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'shared' && (
|
||||
<SharedRoadmapList roadmaps={allRoadmaps?.sharedRoadmaps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
53
src/components/CustomRoadmap/RoadmapRenderer.css
Normal file
53
src/components/CustomRoadmap/RoadmapRenderer.css
Normal file
@ -0,0 +1,53 @@
|
||||
svg text tspan {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic'],
|
||||
svg > g[data-type='subtopic'],
|
||||
svg > g > g[data-type='link-item'],
|
||||
svg > g[data-type='button'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic']:hover > rect {
|
||||
fill: #d6d700;
|
||||
}
|
||||
|
||||
svg > g[data-type='subtopic']:hover > rect {
|
||||
fill: #f3c950;
|
||||
}
|
||||
svg > g[data-type='button']:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
svg .done rect {
|
||||
fill: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done text,
|
||||
svg .skipped text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic'].learning > rect + text,
|
||||
svg > g[data-type='topic'].done > rect + text {
|
||||
fill: black;
|
||||
}
|
||||
|
||||
svg > g[data-type='subtipic'].done > rect + text,
|
||||
svg > g[data-type='subtipic'].learning > rect + text {
|
||||
fill: #cbcbcb;
|
||||
}
|
||||
|
||||
svg .learning rect {
|
||||
fill: #dad1fd !important;
|
||||
}
|
||||
svg .learning text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
svg .skipped rect {
|
||||
fill: #496b69 !important;
|
||||
}
|
177
src/components/CustomRoadmap/RoadmapRenderer.tsx
Normal file
177
src/components/CustomRoadmap/RoadmapRenderer.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Renderer } from '../../../renderer';
|
||||
import './RoadmapRenderer.css';
|
||||
import {
|
||||
renderResourceProgress,
|
||||
updateResourceProgress,
|
||||
type ResourceProgressType,
|
||||
renderTopicProgress,
|
||||
refreshProgressCounters,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { EmptyRoadmap } from './EmptyRoadmap';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
type RoadmapRendererProps = {
|
||||
roadmap: RoadmapDocument;
|
||||
};
|
||||
|
||||
type RoadmapNodeDetails = {
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
targetGroup: SVGElement;
|
||||
};
|
||||
|
||||
export function getNodeDetails(
|
||||
svgElement: SVGElement
|
||||
): RoadmapNodeDetails | null {
|
||||
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
|
||||
|
||||
const nodeId = targetGroup?.dataset?.nodeId;
|
||||
const nodeType = targetGroup?.dataset?.type;
|
||||
if (!nodeId || !nodeType) return null;
|
||||
|
||||
return { nodeId, nodeType, targetGroup };
|
||||
}
|
||||
|
||||
export const allowedClickableNodeTypes = [
|
||||
'topic',
|
||||
'subtopic',
|
||||
'button',
|
||||
'link-item',
|
||||
];
|
||||
|
||||
export function RoadmapRenderer(props: RoadmapRendererProps) {
|
||||
const { roadmap } = props;
|
||||
const roadmapRef = useRef<HTMLDivElement>(null);
|
||||
const roadmapId = roadmap._id!;
|
||||
|
||||
const toast = useToast();
|
||||
const [hideRenderer, setHideRenderer] = useState(false);
|
||||
|
||||
async function updateTopicStatus(
|
||||
topicId: string,
|
||||
newStatus: ResourceProgressType
|
||||
) {
|
||||
pageProgressMessage.set('Updating progress');
|
||||
updateResourceProgress(
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
topicId,
|
||||
},
|
||||
newStatus
|
||||
)
|
||||
.then(() => {
|
||||
renderTopicProgress(topicId, newStatus);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Something went wrong, please try again.');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
refreshProgressCounters();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSvgClick = useCallback((e: MouseEvent) => {
|
||||
const target = e.target as SVGElement;
|
||||
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
|
||||
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
|
||||
return;
|
||||
|
||||
if (nodeType === 'button' || nodeType === 'link-item') {
|
||||
const link = targetGroup?.dataset?.link || '';
|
||||
const isExternalLink = link.startsWith('http');
|
||||
if (isExternalLink) {
|
||||
window.open(link, '_blank');
|
||||
} else {
|
||||
window.location.href = link;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
|
||||
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
|
||||
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
updateTopicStatus(
|
||||
nodeId,
|
||||
isCurrentStatusLearning ? 'pending' : 'learning'
|
||||
);
|
||||
return;
|
||||
} else if (e.altKey) {
|
||||
e.preventDefault();
|
||||
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('roadmap.node.click', {
|
||||
detail: {
|
||||
topicId: nodeId,
|
||||
resourceId: roadmap?._id,
|
||||
resourceType: 'roadmap',
|
||||
isCustomResource: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSvgRightClick = useCallback((e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target as SVGElement;
|
||||
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
|
||||
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
|
||||
return;
|
||||
|
||||
if (nodeType === 'button' || nodeType === 'link-item') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentStatusDone = targetGroup?.classList.contains('done');
|
||||
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roadmapRef?.current) return;
|
||||
roadmapRef?.current?.addEventListener('click', handleSvgClick);
|
||||
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
|
||||
|
||||
return () => {
|
||||
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
|
||||
roadmapRef?.current?.removeEventListener(
|
||||
'contextmenu',
|
||||
handleSvgRightClick
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex grow bg-gray-50 pb-8 pt-4 sm:pt-12">
|
||||
<div className="container !max-w-[1000px]">
|
||||
<Renderer
|
||||
ref={roadmapRef}
|
||||
roadmap={{ nodes: roadmap?.nodes!, edges: roadmap?.edges! }}
|
||||
onRendered={() => {
|
||||
renderResourceProgress('roadmap', roadmapId).then(() => {
|
||||
if (roadmap?.nodes?.length === 0) {
|
||||
setHideRenderer(true);
|
||||
roadmapRef?.current?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{hideRenderer && <EmptyRoadmap />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
162
src/components/CustomRoadmap/ShareRoadmapModal.tsx
Normal file
162
src/components/CustomRoadmap/ShareRoadmapModal.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { Check, Copy, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Modal } from '../Modal';
|
||||
import type { AllowedRoadmapVisibility } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { httpPatch } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import { currentRoadmap, isCurrentRoadmapPersonal } from '../../stores/roadmap';
|
||||
|
||||
type ShareRoadmapModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const allowedVisibilityLabels: {
|
||||
id: AllowedRoadmapVisibility;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'me',
|
||||
label: 'Only visible to me',
|
||||
},
|
||||
{
|
||||
id: 'public',
|
||||
label: 'Anyone with the link',
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
label: 'Visible to team members',
|
||||
},
|
||||
{
|
||||
id: 'friends',
|
||||
label: 'Only friends can view',
|
||||
},
|
||||
];
|
||||
|
||||
export function ShareRoadmapModal(props: ShareRoadmapModalProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const $currentRoadmap = useStore(currentRoadmap);
|
||||
const $isCurrentRoadmapPersonal = useStore(isCurrentRoadmapPersonal);
|
||||
const roadmapId = $currentRoadmap?._id!;
|
||||
|
||||
const { copyText, isCopied } = useCopyText();
|
||||
const [visibility, setVisibility] = useState($currentRoadmap?.visibility);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
async function updateVisibility(newVisibility: AllowedRoadmapVisibility) {
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-roadmap-visibility/${
|
||||
$currentRoadmap?._id
|
||||
}`,
|
||||
{
|
||||
visibility: newVisibility,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
toast.success('Visibility updated');
|
||||
setVisibility(newVisibility);
|
||||
currentRoadmap.set({
|
||||
...$currentRoadmap!,
|
||||
visibility: newVisibility,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
const isDev = import.meta.env.DEV;
|
||||
const url = new URL(
|
||||
isDev ? 'http://localhost:3000/r' : 'https://roadmap.sh/r'
|
||||
);
|
||||
url.searchParams.set('id', roadmapId);
|
||||
copyText(url.toString());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<div className="p-4 pb-0">
|
||||
<h1 className="text-lg font-medium leading-5 text-gray-900">
|
||||
Updating {$currentRoadmap?.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 border-t">
|
||||
{allowedVisibilityLabels.map((v) => {
|
||||
if (v.id === 'team' && $isCurrentRoadmapPersonal) {
|
||||
return null;
|
||||
} else if (v.id === 'friends' && !$isCurrentRoadmapPersonal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={v.id}>
|
||||
<button
|
||||
disabled={v.id === visibility || isLoading}
|
||||
key={v.id}
|
||||
className={cn(
|
||||
'relative flex w-full items-center border-b p-2.5 px-4 text-sm text-gray-700 hover:bg-gray-200 hover:text-gray-900 disabled:cursor-not-allowed',
|
||||
v.id === visibility &&
|
||||
'bg-gray-900 text-white hover:bg-gray-900 hover:text-white'
|
||||
)}
|
||||
onClick={() => updateVisibility(v.id)}
|
||||
>
|
||||
{v.label}
|
||||
|
||||
{v.id === visibility && (
|
||||
<span className="absolute bottom-0 right-0 top-0 flex w-8 items-center justify-center">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<button
|
||||
disabled={isLoading}
|
||||
className="flex h-9 items-center rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-gray-300 focus:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
onClick={onClose}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={14} className="mr-2 animate-spin stroke-[2.5]" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
'Cancel'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="flex h-9 items-center justify-center rounded-md border border-transparent bg-gray-900 px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check size={14} className="mr-2 stroke-[2.5]" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={14} className="mr-2 stroke-[2.5]" />
|
||||
Copy Link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
118
src/components/CustomRoadmap/SharedRoadmapList.tsx
Normal file
118
src/components/CustomRoadmap/SharedRoadmapList.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { ExternalLinkIcon, Map, Plus } from 'lucide-react';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import type { GetRoadmapListResponse } from './RoadmapListPage';
|
||||
|
||||
type GroupByCreator = {
|
||||
creator: GetRoadmapListResponse['sharedRoadmaps'][number]['creator'];
|
||||
roadmaps: GetRoadmapListResponse['sharedRoadmaps'];
|
||||
};
|
||||
|
||||
type SharedRoadmapListProps = {
|
||||
roadmaps: GetRoadmapListResponse['sharedRoadmaps'];
|
||||
};
|
||||
|
||||
export function SharedRoadmapList(props: SharedRoadmapListProps) {
|
||||
const { roadmaps: sharedRoadmaps } = props;
|
||||
|
||||
const allUniqueCreatorIds = new Set(
|
||||
sharedRoadmaps.map((roadmap) => roadmap.creator.id)
|
||||
);
|
||||
|
||||
const groupByCreator: GroupByCreator[] = [];
|
||||
for (const creatorId of allUniqueCreatorIds) {
|
||||
const creator = sharedRoadmaps.find(
|
||||
(roadmap) => roadmap.creator.id === creatorId
|
||||
)?.creator;
|
||||
if (!creator) {
|
||||
continue;
|
||||
}
|
||||
|
||||
groupByCreator.push({
|
||||
creator,
|
||||
roadmaps: sharedRoadmaps.filter(
|
||||
(roadmap) => roadmap.creator.id === creatorId
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (sharedRoadmaps.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 py-20">
|
||||
<Map className="mb-4 h-24 w-24 opacity-10" />
|
||||
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
|
||||
<p className="text-base text-gray-500">
|
||||
Roadmaps from your friends will appear here
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className={'text-sm text-gray-400'}>
|
||||
{sharedRoadmaps.length} shared roadmap(s)
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{groupByCreator.map((group) => {
|
||||
const creator = group.creator;
|
||||
return (
|
||||
<li
|
||||
key={creator.id}
|
||||
className="flex flex-col items-start overflow-hidden rounded-md border border-gray-300"
|
||||
>
|
||||
<div className="relative flex w-full items-center gap-3 p-3">
|
||||
<img
|
||||
src={
|
||||
creator.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
creator.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={creator.name || ''}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="truncate font-medium">{creator.name}</h3>
|
||||
<p className="truncate text-sm text-gray-500">
|
||||
{group?.roadmaps?.length || 0} shared roadmap(s)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="w-full">
|
||||
{group?.roadmaps?.map((roadmap) => {
|
||||
return (
|
||||
<li
|
||||
key={roadmap._id}
|
||||
className="relative flex w-full border-t"
|
||||
>
|
||||
<a
|
||||
href={`/r?id=${roadmap._id}`}
|
||||
className="group inline-grid w-full grid-cols-[auto,16px] items-center justify-between gap-2 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-black"
|
||||
target={'_blank'}
|
||||
>
|
||||
<span className="w-full truncate">
|
||||
{roadmap.title}
|
||||
</span>
|
||||
|
||||
<ExternalLinkIcon
|
||||
size={16}
|
||||
className="opacity-20 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
28
src/components/CustomRoadmap/SkeletonRoadmapHeader.tsx
Normal file
28
src/components/CustomRoadmap/SkeletonRoadmapHeader.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
export function SkeletonRoadmapHeader() {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container relative py-5 sm:py-12">
|
||||
<div className="mb-3 mt-0 sm:mb-4">
|
||||
<div className="h-8 w-1/2 animate-pulse rounded-md bg-gray-300 sm:mb-2 sm:h-10" />
|
||||
<div className="mt-0.5 h-5 w-1/3 animate-pulse rounded-md bg-gray-200 sm:h-7" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 sm:gap-0">
|
||||
<div className="h-7 w-[35.04px] sm:w-32 animate-pulse rounded-md bg-gray-300 sm:h-8" />
|
||||
<div className="h-7 w-[32px] sm:w-[89.73px] animate-pulse rounded-md bg-gray-300 sm:h-8" />
|
||||
</div>
|
||||
|
||||
<div className="mb-0 mt-4 rounded-md border-0 sm:-mb-[65px] sm:mt-7 sm:border">
|
||||
<div
|
||||
data-progress-nums-container
|
||||
className="striped-loader relative hidden h-8 items-center justify-between rounded-md bg-white sm:flex"
|
||||
/>
|
||||
<div
|
||||
data-progress-nums-container
|
||||
className="striped-loader relative -mb-2 flex h-[34px] items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,13 +1,20 @@
|
||||
---
|
||||
import FeaturedItem, { FeaturedItemType } from './FeaturedItem.astro';
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||
import FeaturedItem, { type FeaturedItemType } from './FeaturedItem.astro';
|
||||
|
||||
export interface Props {
|
||||
featuredItems: FeaturedItemType[];
|
||||
heading: string;
|
||||
showCreateRoadmap?: boolean;
|
||||
allowBookmark?: boolean;
|
||||
}
|
||||
|
||||
const { featuredItems, heading, allowBookmark = true } = Astro.props;
|
||||
const {
|
||||
featuredItems,
|
||||
heading,
|
||||
showCreateRoadmap,
|
||||
allowBookmark = true,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class='relative border-b border-b-[#1e293c] py-10 sm:py-14'>
|
||||
@ -32,6 +39,19 @@ const { featuredItems, heading, allowBookmark = true } = Astro.props;
|
||||
</li>
|
||||
))
|
||||
}
|
||||
{
|
||||
showCreateRoadmap && (
|
||||
<li>
|
||||
<CreateRoadmapButton
|
||||
client:load
|
||||
className='min-h-[54px]'
|
||||
type={
|
||||
heading.toLowerCase().indexOf('role') > -1 ? 'role' : 'skill'
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -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}
|
||||
|
@ -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 (
|
||||
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { EmptyProgress } from './EmptyProgress';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { HeroRoadmaps } from './HeroRoadmaps';
|
||||
import {isLoggedIn} from "../../lib/jwt";
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
export type UserProgressResponse = {
|
||||
resourceId: string;
|
||||
@ -14,6 +14,7 @@ export type UserProgressResponse = {
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: Date;
|
||||
isCustomResource: boolean;
|
||||
}[];
|
||||
|
||||
function renderProgress(progressList: UserProgressResponse) {
|
||||
@ -48,6 +49,8 @@ function renderProgress(progressList: UserProgressResponse) {
|
||||
});
|
||||
}
|
||||
|
||||
type ProgressResponse = UserProgressResponse;
|
||||
|
||||
export function FavoriteRoadmaps() {
|
||||
const isAuthenticated = isLoggedIn();
|
||||
if (!isAuthenticated) {
|
||||
@ -56,7 +59,7 @@ export function FavoriteRoadmaps() {
|
||||
|
||||
const [isPreparing, setIsPreparing] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [progress, setProgress] = useState<UserProgressResponse>([]);
|
||||
const [progress, setProgress] = useState<ProgressResponse>([]);
|
||||
const [containerOpacity, setContainerOpacity] = useState(0);
|
||||
|
||||
function showProgressContainer() {
|
||||
@ -79,10 +82,9 @@ export function FavoriteRoadmaps() {
|
||||
async function loadProgress() {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response: progressList, error } =
|
||||
await httpGet<UserProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
|
||||
);
|
||||
const { response: progressList, error } = await httpGet<ProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`
|
||||
);
|
||||
|
||||
if (error || !progressList) {
|
||||
return;
|
||||
@ -111,7 +113,9 @@ export function FavoriteRoadmaps() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasProgress = progress.length > 0;
|
||||
const hasProgress = progress?.length > 0;
|
||||
const customRoadmaps = progress?.filter((p) => p.isCustomResource);
|
||||
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -120,9 +124,14 @@ export function FavoriteRoadmaps() {
|
||||
}`}
|
||||
>
|
||||
<div className="container min-h-full">
|
||||
{!isLoading && progress.length == 0 && <EmptyProgress />}
|
||||
{progress.length > 0 && (
|
||||
<HeroRoadmaps customRoadmaps={[]} progress={progress} isLoading={isLoading} />
|
||||
{!isLoading && progress?.length == 0 && <EmptyProgress />}
|
||||
{hasProgress && (
|
||||
<HeroRoadmaps
|
||||
showCustomRoadmaps={true}
|
||||
customRoadmaps={customRoadmaps}
|
||||
progress={defaultRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -4,6 +4,9 @@ import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { MapIcon } from 'lucide-react';
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { useState } from 'react';
|
||||
|
||||
type ProgressRoadmapProps = {
|
||||
url: string;
|
||||
@ -73,21 +76,20 @@ export function HeroTitle(props: ProgressTitleProps) {
|
||||
|
||||
type ProgressListProps = {
|
||||
progress: UserProgressResponse;
|
||||
showCustomRoadmaps?: boolean;
|
||||
customRoadmaps: any[]; // @fixme implement this
|
||||
customRoadmaps: UserProgressResponse;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function HeroRoadmaps(props: ProgressListProps) {
|
||||
const {
|
||||
progress,
|
||||
isLoading = false,
|
||||
customRoadmaps = [{} /* @fixme implement this */],
|
||||
showCustomRoadmaps = false,
|
||||
} = props;
|
||||
const { progress, isLoading = false, customRoadmaps } = props;
|
||||
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative pb-12 pt-4 sm:pt-7">
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
|
||||
)}
|
||||
{
|
||||
<HeroTitle
|
||||
icon={
|
||||
@ -118,38 +120,50 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showCustomRoadmaps && (
|
||||
<div className="mt-5">
|
||||
{
|
||||
<HeroTitle
|
||||
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
title="Your custom roadmaps"
|
||||
/>
|
||||
}
|
||||
<div className="mt-5">
|
||||
{
|
||||
<HeroTitle
|
||||
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
title="Your custom roadmaps"
|
||||
/>
|
||||
}
|
||||
|
||||
{customRoadmaps.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
|
||||
You haven't created any custom roadmaps yet.{' '}
|
||||
<button className="text-gray-500 underline underline-offset-2 hover:text-gray-400">
|
||||
Create one!
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{customRoadmaps.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
|
||||
You haven't created any custom roadmaps yet.{' '}
|
||||
<button
|
||||
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
|
||||
onClick={() => setIsCreatingRoadmap(true)}
|
||||
>
|
||||
Create one!
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{customRoadmaps.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
{customRoadmaps.map((customRoadmap) => (
|
||||
<HeroRoadmap
|
||||
resourceId={'343434'}
|
||||
resourceType={'roadmap'}
|
||||
resourceTitle={'Frontend Roadmap Revised'}
|
||||
percentageDone={50}
|
||||
url={`/r?${'34343434'}`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
))}
|
||||
{customRoadmaps.map((customRoadmap) => {
|
||||
return (
|
||||
<HeroRoadmap
|
||||
key={customRoadmap.resourceId}
|
||||
resourceId={customRoadmap.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
resourceTitle={customRoadmap.resourceTitle}
|
||||
percentageDone={
|
||||
((customRoadmap.skipped + customRoadmap.done) /
|
||||
customRoadmap.total) *
|
||||
100
|
||||
}
|
||||
url={`/r?id=${customRoadmap.resourceId}`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<CreateRoadmapButton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
46
src/components/Modal.tsx
Normal file
46
src/components/Modal.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { type ReactNode, useRef } from 'react';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { useKeydown } from '../hooks/use-keydown';
|
||||
import { cn } from '../lib/classname';
|
||||
|
||||
type ModalProps = {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
bodyClassName?: string;
|
||||
wrapperClassName?: string;
|
||||
};
|
||||
|
||||
export function Modal(props: ModalProps) {
|
||||
const { onClose, children, bodyClassName, wrapperClassName } = props;
|
||||
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="popup fixed left-0 right-0 top-0 z-[99] flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full w-full max-w-md p-4 md:h-auto',
|
||||
wrapperClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
className={cn(
|
||||
'popup-body relative h-full rounded-lg bg-white shadow',
|
||||
bodyClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -4,12 +4,12 @@ import Icon from '../AstroIcon.astro';
|
||||
|
||||
<div class='relative hidden' data-auth-required>
|
||||
<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]'
|
||||
|
52
src/components/Navigation/AccountDropdown.tsx
Normal file
52
src/components/Navigation/AccountDropdown.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { AccountDropdownList } from './AccountDropdownList';
|
||||
import { DropdownTeamList } from './DropdownTeamList';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
|
||||
export function AccountDropdown() {
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setShowDropdown(false);
|
||||
setIsTeamsOpen(false);
|
||||
});
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-50 animate-fade-in">
|
||||
<button
|
||||
className="flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
|
||||
onClick={() => {
|
||||
setIsTeamsOpen(false);
|
||||
setShowDropdown(!showDropdown);
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
Account <span className="text-gray-300">/</span> Teams
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
|
||||
>
|
||||
{isTeamsOpen ? (
|
||||
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
|
||||
) : (
|
||||
<AccountDropdownList setIsTeamsOpen={setIsTeamsOpen} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
49
src/components/Navigation/AccountDropdownList.tsx
Normal file
49
src/components/Navigation/AccountDropdownList.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { logout } from './navigation';
|
||||
|
||||
type AccountDropdownListProps = {
|
||||
setIsTeamsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export function AccountDropdownList(props: AccountDropdownListProps) {
|
||||
const { setIsTeamsOpen } = props;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
<li className="px-1">
|
||||
<a
|
||||
href="/account"
|
||||
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li className="px-1">
|
||||
<a
|
||||
href="/account/friends"
|
||||
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
Friends
|
||||
</a>
|
||||
</li>
|
||||
<li className="px-1">
|
||||
<button
|
||||
className="group flex w-full items-center justify-between rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
onClick={() => setIsTeamsOpen(true)}
|
||||
>
|
||||
Teams
|
||||
<ChevronRight className="h-4 w-4 shrink-0 stroke-[2.5px] text-slate-400 group-hover:text-white" />
|
||||
</button>
|
||||
</li>
|
||||
<li className="px-1">
|
||||
<button
|
||||
className="block w-full rounded pl-4 pr-2 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
type="button"
|
||||
onClick={logout}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
110
src/components/Navigation/DropdownTeamList.tsx
Normal file
110
src/components/Navigation/DropdownTeamList.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { ChevronLeft, Loader2, Plus, Users } from 'lucide-react';
|
||||
import { $teamList } from '../../stores/team';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
|
||||
type DropdownTeamListProps = {
|
||||
setIsTeamsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export function DropdownTeamList(props: DropdownTeamListProps) {
|
||||
const { setIsTeamsOpen } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const teamList = useStore($teamList);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function getAllTeams() {
|
||||
if (teamList.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpGet<TeamListResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
$teamList.set(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getAllTeams().finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadingIndicator = isLoading && (
|
||||
<div className="mt-2 flex animate-pulse flex-col gap-1 px-1 text-center">
|
||||
<div className="h-[35px] rounded-md bg-gray-700"></div>
|
||||
<div className="h-[35px] rounded-md bg-gray-700"></div>
|
||||
<div className="h-[35px] rounded-md bg-gray-700"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<button
|
||||
className="mt-1 flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-slate-50/10 hover:text-slate-50"
|
||||
onClick={() => setIsTeamsOpen(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 stroke-[2.5px]" />
|
||||
</button>
|
||||
<a
|
||||
className="mt-1 flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-slate-50/10 hover:text-slate-50"
|
||||
href="/team/new"
|
||||
>
|
||||
<Plus className="h-4 w-4 stroke-[2.5px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{loadingIndicator}
|
||||
{!isLoading && (
|
||||
<ul className="mt-2">
|
||||
{teamList?.map((team) => {
|
||||
let pageLink = '';
|
||||
if (team.status === 'invited') {
|
||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||
} else if (team.status === 'joined') {
|
||||
pageLink = `/team/progress?t=${team._id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={team._id} className="px-1">
|
||||
<a
|
||||
href={pageLink}
|
||||
className="block truncate rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
{team.name}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{teamList.length === 0 && !isLoading && (
|
||||
<li className="mt-2 px-1 text-center">
|
||||
<p className="block rounded px-4 py-2 text-sm font-medium text-slate-500">
|
||||
<Users className="mx-auto mb-2 h-7 w-7 text-slate-600" />
|
||||
No teams found.{' '}
|
||||
<a
|
||||
className="font-medium text-slate-400 underline underline-offset-2 hover:text-slate-300"
|
||||
href="/team/new"
|
||||
>
|
||||
Create a team
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
import Icon from '../AstroIcon.astro';
|
||||
import AccountDropdown from './AccountDropdown.astro';
|
||||
import { AccountDropdown } from './AccountDropdown';
|
||||
---
|
||||
|
||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||
@ -24,7 +24,8 @@ import AccountDropdown from './AccountDropdown.astro';
|
||||
>
|
||||
</li>
|
||||
<li class='hidden lg:inline'>
|
||||
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a>
|
||||
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a
|
||||
>
|
||||
</li>
|
||||
<li class='hidden lg:inline'>
|
||||
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
|
||||
@ -44,7 +45,7 @@ import AccountDropdown from './AccountDropdown.astro';
|
||||
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
||||
</li>
|
||||
<li>
|
||||
<AccountDropdown />
|
||||
<AccountDropdown client:only="react" />
|
||||
|
||||
<a
|
||||
data-guest-required
|
||||
@ -108,6 +109,11 @@ import AccountDropdown from './AccountDropdown.astro';
|
||||
Account
|
||||
</a>
|
||||
</li>
|
||||
<li data-auth-required class='hidden'>
|
||||
<a href='/team' class='text-xl hover:text-blue-300 md:text-lg'>
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
<li data-auth-required class='hidden'>
|
||||
<button
|
||||
data-logout-button
|
||||
|
64
src/components/ShareOptions/CopyRoadmapLink.tsx
Normal file
64
src/components/ShareOptions/CopyRoadmapLink.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { CheckCircle, Copy } from 'lucide-react';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type CopyRoadmapLinkProps = {
|
||||
roadmapId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CopyRoadmapLink(props: CopyRoadmapLinkProps) {
|
||||
const { roadmapId, onClose } = props;
|
||||
|
||||
const shareLink = `${
|
||||
import.meta.env.PUBLIC_ROADMAP_WEB_URL
|
||||
}/r?id=${roadmapId}`;
|
||||
const { copyText, isCopied } = useCopyText();
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col justify-center">
|
||||
<div className="mt-5 flex grow flex-col items-center justify-center gap-1.5">
|
||||
<CheckCircle className="h-14 w-14 text-green-500" />
|
||||
<h3 className="text-xl font-medium">Sharing Settings Updated</h3>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="mt-6 w-full rounded-md border bg-gray-50 p-2 px-2.5 text-gray-700 focus:outline-none"
|
||||
value={shareLink}
|
||||
readOnly
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(shareLink);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
You can share the above link with anyone who has access
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-col items-center justify-end gap-2">
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-1.5 rounded bg-black px-4 py-2.5 text-sm font-medium text-white hover:opacity-80',
|
||||
isCopied && 'bg-green-300 text-green-800'
|
||||
)}
|
||||
disabled={isCopied}
|
||||
onClick={() => {
|
||||
copyText(shareLink);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5 stroke-[2.5]" />
|
||||
{isCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-1.5 rounded border border-black px-4 py-2 text-sm font-medium hover:bg-gray-100'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
160
src/components/ShareOptions/ShareFriendList.tsx
Normal file
160
src/components/ShareOptions/ShareFriendList.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { UserItem } from './UserItem';
|
||||
import { Users2 } from 'lucide-react';
|
||||
import {httpGet} from "../../lib/http";
|
||||
|
||||
export type FriendshipStatus =
|
||||
| 'none'
|
||||
| 'sent'
|
||||
| 'received'
|
||||
| 'accepted'
|
||||
| 'rejected'
|
||||
| 'got_rejected';
|
||||
|
||||
type FriendResourceProgress = {
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
done: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ListFriendsResponse = {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
status: FriendshipStatus;
|
||||
roadmaps: FriendResourceProgress[];
|
||||
bestPractices: FriendResourceProgress[];
|
||||
}[];
|
||||
|
||||
type ShareFriendListProps = {
|
||||
setFriends: (friends: ListFriendsResponse) => void;
|
||||
friends: ListFriendsResponse;
|
||||
sharedFriendIds: string[];
|
||||
setSharedFriendIds: (friendIds: string[]) => void;
|
||||
};
|
||||
|
||||
export function ShareFriendList(props: ShareFriendListProps) {
|
||||
const { setFriends, friends, sharedFriendIds, setSharedFriendIds } = props;
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function loadFriends() {
|
||||
if (friends.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpGet<ListFriendsResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setFriends(response.filter((friend) => friend.status === 'accepted'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFriends().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadingFriends = isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{[...Array(3)].map((_, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex animate-pulse items-center gap-2.5 rounded-md border p-2"
|
||||
>
|
||||
<div className="relative top-[1px] h-10 w-10 shrink-0 rounded-full bg-gray-200" />
|
||||
<div className="inline-grid w-full">
|
||||
<div className="h-5 w-2/4 rounded bg-gray-200" />
|
||||
<div className="mt-1 h-5 w-3/4 rounded bg-gray-200" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(friends.length > 0 || isLoading) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm">Select Friends to share the roadmap with</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sharedFriendIds.length === friends.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSharedFriendIds(friends.map((f) => f.userId));
|
||||
} else {
|
||||
setSharedFriendIds([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">Select all</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingFriends}
|
||||
{friends.length > 0 && !isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{friends.map((friend) => {
|
||||
const isSelected = sharedFriendIds?.includes(friend.userId);
|
||||
return (
|
||||
<li key={friend.userId}>
|
||||
<UserItem
|
||||
user={{
|
||||
name: friend.name,
|
||||
avatar: friend.avatar,
|
||||
email: friend.email,
|
||||
}}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSharedFriendIds(
|
||||
sharedFriendIds.filter((id) => id !== friend.userId)
|
||||
);
|
||||
} else {
|
||||
setSharedFriendIds([...sharedFriendIds, friend.userId]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{friends.length === 0 && !isLoading && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Users2 className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-semibold text-gray-500">
|
||||
You do not have any friends yet. <br />{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
href={`${import.meta.env.PUBLIC_ROADMAP_WEB_URL}/account/friends`}
|
||||
>
|
||||
Invite your friends to share roadmaps with.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
311
src/components/ShareOptions/ShareOptionsModal.tsx
Normal file
311
src/components/ShareOptions/ShareOptionsModal.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
import { Globe2, Loader2, Lock } from 'lucide-react';
|
||||
import { type ListFriendsResponse, ShareFriendList } from './ShareFriendList';
|
||||
import { TransferToTeamList } from './TransferToTeamList';
|
||||
import { ShareOptionTabs } from './ShareOptionsTab';
|
||||
import {
|
||||
ShareTeamMemberList,
|
||||
type TeamMemberList,
|
||||
} from './ShareTeamMemberList';
|
||||
import { CopyRoadmapLink } from './CopyRoadmapLink';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { httpPatch } from '../../lib/http';
|
||||
import { Modal } from '../Modal';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { UserTeamItem } from '../TeamDropdown/TeamDropdown';
|
||||
|
||||
export type OnShareSettingsUpdate = (options: {
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
sharedTeamMemberIds: string[];
|
||||
sharedFriendIds: string[];
|
||||
}) => void;
|
||||
|
||||
type ShareOptionsModalProps = {
|
||||
onClose: () => void;
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
sharedFriendIds?: string[];
|
||||
sharedTeamMemberIds?: string[];
|
||||
teamId?: string;
|
||||
roadmapId?: string;
|
||||
|
||||
onShareSettingsUpdate: OnShareSettingsUpdate;
|
||||
};
|
||||
|
||||
export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
const {
|
||||
roadmapId,
|
||||
onClose,
|
||||
visibility: defaultVisibility,
|
||||
sharedTeamMemberIds: defaultSharedMemberIds = [],
|
||||
sharedFriendIds: defaultSharedFriendIds = [],
|
||||
teamId,
|
||||
onShareSettingsUpdate,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
|
||||
const [friends, setFriends] = useState<ListFriendsResponse>([]);
|
||||
const [teams, setTeams] = useState<UserTeamItem[]>([]);
|
||||
const [members, setMembers] = useState<TeamMemberList[]>([]);
|
||||
|
||||
const [visibility, setVisibility] = useState(defaultVisibility);
|
||||
const [sharedTeamMemberIds, setSharedTeamMemberIds] = useState<string[]>(
|
||||
defaultSharedMemberIds
|
||||
);
|
||||
const [sharedFriendIds, setSharedFriendIds] = useState<string[]>(
|
||||
defaultSharedFriendIds
|
||||
);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
|
||||
const canTransferRoadmap = visibility === 'team' && !teamId;
|
||||
let isUpdateDisabled = false;
|
||||
// Disable update button if there are no friends to share with
|
||||
if (visibility === 'friends' && sharedFriendIds.length === 0) {
|
||||
isUpdateDisabled = true;
|
||||
// Disable update button if there are no team to transfer
|
||||
} else if (canTransferRoadmap && !selectedTeamId) {
|
||||
isUpdateDisabled = true;
|
||||
// Disable update button if there are no members to share with
|
||||
} else if (
|
||||
visibility === 'team' &&
|
||||
teamId &&
|
||||
sharedTeamMemberIds.length === 0
|
||||
) {
|
||||
isUpdateDisabled = true;
|
||||
}
|
||||
|
||||
const handleShareChange: OnShareSettingsUpdate = async ({
|
||||
sharedFriendIds,
|
||||
visibility,
|
||||
sharedTeamMemberIds,
|
||||
}) => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (visibility === 'friends' && sharedFriendIds.length === 0) {
|
||||
toast.error('Please select at least one friend');
|
||||
return;
|
||||
} else if (
|
||||
visibility === 'team' &&
|
||||
teamId &&
|
||||
sharedTeamMemberIds.length === 0
|
||||
) {
|
||||
toast.error('Please select at least one member');
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpPatch(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-roadmap-visibility/${roadmapId}`,
|
||||
{
|
||||
visibility,
|
||||
sharedFriendIds,
|
||||
sharedTeamMemberIds,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsSettingsUpdated(true);
|
||||
onShareSettingsUpdate({ sharedFriendIds, visibility, sharedTeamMemberIds });
|
||||
};
|
||||
|
||||
const handleTransferToTeam = useCallback(
|
||||
async (teamId: string) => {
|
||||
if (!roadmapId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-transfer-roadmap/${roadmapId}`,
|
||||
{
|
||||
teamId,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
},
|
||||
[roadmapId]
|
||||
);
|
||||
|
||||
if (isSettingsUpdated) {
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
wrapperClassName="max-w-lg"
|
||||
bodyClassName="p-4 flex flex-col"
|
||||
>
|
||||
<CopyRoadmapLink roadmapId={roadmapId!} onClose={onClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
wrapperClassName="max-w-3xl"
|
||||
bodyClassName="p-4 flex flex-col min-h-[400px]"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-xl font-semibold">Update Sharing Settings</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Pick and modify who can access this roadmap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ShareOptionTabs
|
||||
visibility={visibility}
|
||||
setVisibility={setVisibility}
|
||||
teamId={teamId}
|
||||
onChange={(visibility) => {
|
||||
setSelectedTeamId(null);
|
||||
|
||||
if (['me', 'public'].includes(visibility)) {
|
||||
setSharedTeamMemberIds([]);
|
||||
setSharedFriendIds([]);
|
||||
} else if (visibility === 'friends') {
|
||||
setSharedFriendIds(
|
||||
defaultSharedFriendIds.length > 0 ? defaultSharedFriendIds : []
|
||||
);
|
||||
} else if (visibility === 'team' && teamId) {
|
||||
setSharedTeamMemberIds(
|
||||
defaultSharedMemberIds?.length > 0 ? defaultSharedMemberIds : []
|
||||
);
|
||||
setSharedFriendIds([]);
|
||||
} else {
|
||||
setSharedFriendIds([]);
|
||||
setSharedTeamMemberIds([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex grow flex-col">
|
||||
{visibility === 'public' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Globe2 className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Anyone with the link can access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{visibility === 'me' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Lock className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Only you will be able to access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For Personal Roadmap */}
|
||||
{visibility === 'friends' && (
|
||||
<ShareFriendList
|
||||
friends={friends}
|
||||
setFriends={setFriends}
|
||||
sharedFriendIds={sharedFriendIds}
|
||||
setSharedFriendIds={setSharedFriendIds}
|
||||
/>
|
||||
)}
|
||||
{canTransferRoadmap && (
|
||||
<TransferToTeamList
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
selectedTeamId={selectedTeamId}
|
||||
setSelectedTeamId={setSelectedTeamId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* For Team Roadmap */}
|
||||
{visibility === 'team' && teamId && (
|
||||
<ShareTeamMemberList
|
||||
teamId={teamId}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
members={members}
|
||||
setMembers={setMembers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-1.5">
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 rounded-md border px-3.5 py-1.5 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-75"
|
||||
disabled={isLoading}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
{canTransferRoadmap ? (
|
||||
<UpdateAction
|
||||
disabled={isUpdateDisabled || isLoading}
|
||||
onClick={() => {
|
||||
handleTransferToTeam(selectedTeamId!).then(() => null);
|
||||
}}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Transfer
|
||||
</UpdateAction>
|
||||
) : (
|
||||
<UpdateAction
|
||||
disabled={isUpdateDisabled || isLoading}
|
||||
onClick={() => {
|
||||
handleShareChange({
|
||||
visibility,
|
||||
sharedTeamMemberIds:
|
||||
visibility === 'team' ? sharedTeamMemberIds : [],
|
||||
sharedFriendIds:
|
||||
visibility === 'friends' ? sharedFriendIds : [],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Update Sharing Settings
|
||||
</UpdateAction>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateAction(props: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { onClick, disabled, children, className } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-w-[120px] items-center justify-center gap-1.5 rounded-md border border-gray-900 bg-gray-900 px-4 py-2 text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-75',
|
||||
disabled && 'border-gray-700 bg-gray-700 text-white hover:bg-gray-700',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
129
src/components/ShareOptions/ShareOptionsTab.tsx
Normal file
129
src/components/ShareOptions/ShareOptionsTab.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Check,
|
||||
Globe2,
|
||||
Lock,
|
||||
Users,
|
||||
Users2,
|
||||
} from 'lucide-react';
|
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
export const allowedVisibilityLabels: {
|
||||
id: AllowedRoadmapVisibility;
|
||||
label: string;
|
||||
long: string;
|
||||
icon: typeof Lock;
|
||||
}[] = [
|
||||
{
|
||||
id: 'me',
|
||||
label: 'Only me',
|
||||
long: 'Only visible to me',
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
id: 'public',
|
||||
label: 'Public',
|
||||
long: 'Anyone can view',
|
||||
icon: Globe2,
|
||||
},
|
||||
{
|
||||
id: 'friends',
|
||||
label: 'Only friends',
|
||||
long: 'Only friends can view',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
label: 'Only Members',
|
||||
long: 'Visible to team members',
|
||||
icon: Users2,
|
||||
},
|
||||
];
|
||||
|
||||
type ShareOptionTabsProps = {
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
setVisibility: (visibility: AllowedRoadmapVisibility) => void;
|
||||
teamId?: string;
|
||||
|
||||
onChange: (visibility: AllowedRoadmapVisibility) => void;
|
||||
};
|
||||
|
||||
export function ShareOptionTabs(props: ShareOptionTabsProps) {
|
||||
const { visibility, setVisibility, teamId, onChange } = props;
|
||||
|
||||
const handleClick = (visibility: AllowedRoadmapVisibility) => {
|
||||
setVisibility(visibility);
|
||||
onChange(visibility);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<ul className="flex w-full items-center gap-1.5">
|
||||
{allowedVisibilityLabels.map((v) => {
|
||||
if (v.id === 'friends' && teamId) {
|
||||
return null;
|
||||
} else if (v.id === 'team' && !teamId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive = v.id === visibility;
|
||||
return (
|
||||
<li key={v.id}>
|
||||
<OptionTab
|
||||
label={v.label}
|
||||
isActive={isActive}
|
||||
icon={v.icon}
|
||||
onClick={() => {
|
||||
handleClick(v.id);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{!teamId && (
|
||||
<div className="grow">
|
||||
<OptionTab
|
||||
label="Transfer to team"
|
||||
icon={ArrowLeftRight}
|
||||
isActive={visibility === 'team'}
|
||||
onClick={() => {
|
||||
handleClick('team');
|
||||
}}
|
||||
className='border-red-300 text-red-600 hover:border-red-200 hover:bg-red-50 data-[active="true"]:border-red-600 data-[active="true"]:bg-red-600 data-[active="true"]:text-white'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OptionTabProps = {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
icon: typeof Lock;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function OptionTab(props: OptionTabProps) {
|
||||
const { label, isActive, onClick, icon: Icon, className } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-black hover:border-gray-300 hover:bg-gray-100',
|
||||
'data-[active="true"]:border-gray-500 data-[active="true"]:bg-gray-200 data-[active="true"]:text-black',
|
||||
className
|
||||
)}
|
||||
data-active={isActive}
|
||||
disabled={isActive}
|
||||
onClick={onClick}
|
||||
>
|
||||
{!isActive && <Icon className="h-4 w-4" />}
|
||||
{isActive && <Check className="h-4 w-4" />}
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
164
src/components/ShareOptions/ShareTeamMemberList.tsx
Normal file
164
src/components/ShareOptions/ShareTeamMemberList.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { UserItem } from './UserItem';
|
||||
import { Users } from 'lucide-react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
const allowedRoles = ['admin', 'manager', 'member'] as const;
|
||||
const allowedStatus = ['invited', 'joined', 'rejected'] as const;
|
||||
|
||||
export type AllowedMemberRoles = (typeof allowedRoles)[number];
|
||||
export type AllowedMemberStatus = (typeof allowedStatus)[number];
|
||||
|
||||
export interface TeamMemberDocument {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
invitedEmail?: string;
|
||||
teamId: string;
|
||||
role: AllowedMemberRoles;
|
||||
status: AllowedMemberStatus;
|
||||
progressReminderCount: number;
|
||||
lastProgressReminderAt?: Date;
|
||||
lastResendInviteAt?: Date;
|
||||
resendInviteCount?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TeamMemberList extends TeamMemberDocument {
|
||||
name: string;
|
||||
avatar: string;
|
||||
hasProgress: boolean;
|
||||
}
|
||||
|
||||
type ShareTeamMemberListProps = {
|
||||
teamId: string;
|
||||
setMembers: (members: TeamMemberList[]) => void;
|
||||
members: TeamMemberList[];
|
||||
sharedTeamMemberIds: string[];
|
||||
setSharedTeamMemberIds: (sharedTeamMemberIds: string[]) => void;
|
||||
};
|
||||
|
||||
export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
|
||||
const {
|
||||
setMembers,
|
||||
members,
|
||||
sharedTeamMemberIds,
|
||||
setSharedTeamMemberIds,
|
||||
teamId,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function loadTeamMembers() {
|
||||
if (members?.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpGet<TeamMemberList[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-list/${teamId}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setMembers(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTeamMembers().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadingMembers = isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-2.5">
|
||||
{[...Array(3)].map((_, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex min-h-[62px] animate-pulse items-center gap-2 rounded-md border p-2"
|
||||
>
|
||||
<div className="h-8 w-8 shrink-0 rounded-full bg-gray-200" />
|
||||
<div className="inline-grid w-full">
|
||||
<div className="h-5 w-2/4 rounded bg-gray-200" />
|
||||
<div className="mt-1 h-5 w-3/4 rounded bg-gray-200" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(members.length > 0 || isLoading) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm">Select Members</p>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sharedTeamMemberIds.length === members.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSharedTeamMemberIds(members.map((member) => member._id!));
|
||||
} else {
|
||||
setSharedTeamMemberIds([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">Select all</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingMembers}
|
||||
{members?.length > 0 && !isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-2.5">
|
||||
{members?.map((member) => {
|
||||
const isSelected = sharedTeamMemberIds?.includes(
|
||||
member._id?.toString()!
|
||||
);
|
||||
return (
|
||||
<li key={member.userId}>
|
||||
<UserItem
|
||||
user={{
|
||||
name: member.name,
|
||||
avatar: member.avatar,
|
||||
email: member.invitedEmail!,
|
||||
}}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSharedTeamMemberIds(
|
||||
sharedTeamMemberIds.filter(
|
||||
(id) => id !== member._id?.toString()!
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setSharedTeamMemberIds([
|
||||
...sharedTeamMemberIds,
|
||||
member._id?.toString()!,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{members.length === 0 && !isLoading && (
|
||||
<div className="flex grow flex-col items-center justify-center gap-2">
|
||||
<Users className="h-12 w-12 text-gray-500" />
|
||||
<p className="text-gray-500">No members have been added yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
114
src/components/ShareOptions/TransferToTeamList.tsx
Normal file
114
src/components/ShareOptions/TransferToTeamList.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { Users2 } from 'lucide-react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { UserTeamItem } from '../TeamDropdown/TeamDropdown';
|
||||
|
||||
type TransferToTeamListProps = {
|
||||
teams: UserTeamItem[];
|
||||
setTeams: (teams: UserTeamItem[]) => void;
|
||||
|
||||
selectedTeamId: string | null;
|
||||
setSelectedTeamId: (teamId: string | null) => void;
|
||||
};
|
||||
|
||||
export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
const { teams, setTeams, selectedTeamId, setSelectedTeamId } = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function getAllTeams() {
|
||||
if (teams.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<UserTeamItem[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeams(
|
||||
response.filter((team) => ['admin', 'manager'].includes(team.role))
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getAllTeams().finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadingTeams = isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<li key={index}>
|
||||
<div className="relative flex w-full items-center gap-2 rounded-md border p-2">
|
||||
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-gray-200" />
|
||||
<div className="inline-grid w-full">
|
||||
<div className="h-4 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(teams.length > 0 || isLoading) && (
|
||||
<p className="text-sm">Select a team to transfer this roadmap to</p>
|
||||
)}
|
||||
|
||||
{loadingTeams}
|
||||
{teams.length > 0 && !isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{teams.map((team) => {
|
||||
const isSelected = team._id === selectedTeamId;
|
||||
|
||||
return (
|
||||
<li key={team._id}>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5',
|
||||
isSelected && 'border-gray-500 bg-gray-100 text-black'
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedTeamId(team._id);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
team.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
team.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={team.name || ''}
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
/>
|
||||
<div className="inline-grid w-full">
|
||||
<h3 className="truncate text-left font-normal">
|
||||
{team.name}
|
||||
</h3>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{teams.length === 0 && !isLoading && (
|
||||
<div className="flex grow flex-col items-center justify-center gap-2">
|
||||
<Users2 className="h-12 w-12 text-gray-500" />
|
||||
<p className="text-gray-500">You are not a member of any team.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
46
src/components/ShareOptions/UserItem.tsx
Normal file
46
src/components/ShareOptions/UserItem.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type UserItemProps = {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
export function UserItem(props: UserItemProps) {
|
||||
const { user, onClick, isSelected } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5',
|
||||
isSelected && 'border-gray-500 bg-gray-300 text-black'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
user.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={user.name || ''}
|
||||
className="relative top-[1px] h-10 w-10 shrink-0 rounded-full"
|
||||
/>
|
||||
<div className="inline-grid w-full">
|
||||
<h3 className="truncate text-left font-semibold">{user.name}</h3>
|
||||
<p
|
||||
className={cn(
|
||||
'truncate text-left text-sm text-gray-500',
|
||||
isSelected && 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { Fragment } from 'react';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
|
||||
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>
|
||||
|
@ -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 && (
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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'}
|
||||
>
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
3
src/components/TeamRoadmap/CustomTeamRoadmap.tsx
Normal file
3
src/components/TeamRoadmap/CustomTeamRoadmap.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function CustomTeamRoadmap() {
|
||||
return null;
|
||||
}
|
3
src/components/TeamRoadmap/DefaultTeamRoadmap.tsx
Normal file
3
src/components/TeamRoadmap/DefaultTeamRoadmap.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function DefaultTeamRoadmap() {
|
||||
return null;
|
||||
}
|
@ -1,346 +0,0 @@
|
||||
import { getUrlParams } from '../lib/browser';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
|
||||
import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
|
||||
import { httpGet, httpPut } from '../lib/http';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import ExternalLinkIcon from '../icons/external-link.svg';
|
||||
import RoadmapIcon from '../icons/roadmap.svg';
|
||||
import PlusIcon from '../icons/plus.svg';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { UpdateTeamResourceModal } from './CreateTeam/UpdateTeamResourceModal';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $canManageCurrentTeam } from '../stores/team';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
import { SelectRoadmapModal } from './CreateTeam/SelectRoadmapModal';
|
||||
|
||||
export function TeamRoadmaps() {
|
||||
const { t: teamId } = getUrlParams();
|
||||
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
|
||||
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [team, setTeam] = useState<TeamDocument>();
|
||||
const [resourceConfigs, setResourceConfigs] = useState<TeamResourceConfig>(
|
||||
[]
|
||||
);
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
|
||||
async function loadAllRoadmaps() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allRoadmaps = response
|
||||
.filter((page) => page.group === 'Roadmaps')
|
||||
.sort((a, b) => {
|
||||
if (a.title === 'Android') return 1;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
setAllRoadmaps(allRoadmaps);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function loadTeam(teamIdToFetch: string) {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error('Error loading team');
|
||||
window.location.href = '/account';
|
||||
return;
|
||||
}
|
||||
|
||||
setTeam(response);
|
||||
}
|
||||
|
||||
async function loadTeamResourceConfig(teamId: string) {
|
||||
const { error, response } = await httpGet<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
|
||||
);
|
||||
if (error || !Array.isArray(response)) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Promise.all([
|
||||
loadTeam(teamId),
|
||||
loadTeamResourceConfig(teamId),
|
||||
loadAllRoadmaps(),
|
||||
]).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
async function deleteResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading('Deleting roadmap');
|
||||
pageProgressMessage.set(`Deleting roadmap from team`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Roadmap removed');
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
async function onAdd(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading('Adding roadmap');
|
||||
pageProgressMessage.set('Adding roadmap');
|
||||
setIsLoading(true);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setResourceConfigs(response);
|
||||
toast.success('Roadmap added');
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
pageProgressMessage.set('Removing roadmap');
|
||||
|
||||
deleteResource(resourceId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const addRoadmapModal = isAddingRoadmap && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setIsAddingRoadmap(false)}
|
||||
teamResourceConfig={resourceConfigs}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId) => {
|
||||
onAdd(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
onRoadmapRemove={(roadmapId) => {
|
||||
if (confirm('Are you sure you want to remove this roadmap?')) {
|
||||
onRemove(roadmapId).finally(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (resourceConfigs.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 py-20">
|
||||
{addRoadmapModal}
|
||||
<img
|
||||
alt="roadmap"
|
||||
src={RoadmapIcon.src}
|
||||
className="mb-4 h-24 w-24 opacity-10"
|
||||
/>
|
||||
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
|
||||
<p className="text-base text-gray-500">
|
||||
{canManageCurrentTeam
|
||||
? 'Add a roadmap to start tracking your team'
|
||||
: 'Ask your team admin to add some roadmaps'}
|
||||
</p>
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<button
|
||||
className="mt-4 rounded-lg bg-black px-4 py-2 font-medium text-white hover:bg-gray-900"
|
||||
onClick={() => setIsAddingRoadmap(true)}
|
||||
>
|
||||
Add roadmap
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{addRoadmapModal}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className={'text-gray-400'}>
|
||||
{resourceConfigs.length} roadmap(s) selected
|
||||
</span>
|
||||
{canManageCurrentTeam && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-gray-500 underline hover:bg-gray-100 hover:text-gray-900"
|
||||
onClick={() => setIsAddingRoadmap(true)}
|
||||
>
|
||||
Add / Remove Roadmaps
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={'grid grid-cols-1 gap-3 sm:grid-cols-2'}>
|
||||
{changingRoadmapId && (
|
||||
<UpdateTeamResourceModal
|
||||
onClose={() => setChangingRoadmapId('')}
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={team?._id!}
|
||||
setTeamResourceConfig={setResourceConfigs}
|
||||
defaultRemovedItems={
|
||||
resourceConfigs.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resourceConfigs.map((resourceConfig) => {
|
||||
const { resourceId, removed: removedTopics } = resourceConfig;
|
||||
const roadmapTitle =
|
||||
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
|
||||
'...';
|
||||
|
||||
return (
|
||||
<div key={resourceId} className="flex flex-col items-start rounded-md border border-gray-300">
|
||||
<div className={'w-full px-3 py-4'}>
|
||||
<a
|
||||
href={`/${resourceId}?t=${teamId}`}
|
||||
className="group mb-0.5 flex items-center justify-between text-base font-medium leading-none text-black"
|
||||
target={'_blank'}
|
||||
>
|
||||
{roadmapTitle}
|
||||
|
||||
<img
|
||||
alt={'link'}
|
||||
src={ExternalLinkIcon.src}
|
||||
className="ml-2 h-4 w-4 opacity-20 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
{removedTopics.length > 0 ? (
|
||||
<span className={'text-xs leading-none text-gray-900'}>
|
||||
{removedTopics.length} topic
|
||||
{removedTopics.length > 1 ? 's' : ''} removed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-none text-gray-400/60">
|
||||
No changes made ..
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<div className={'flex w-full justify-between px-3 pb-3 pt-2'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
|
||||
}
|
||||
onClick={() => {
|
||||
setRemovingRoadmapId('');
|
||||
setChangingRoadmapId(resourceId);
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
|
||||
{removingRoadmapId !== resourceId && (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-red-500'
|
||||
}
|
||||
onClick={() => setRemovingRoadmapId(resourceId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
|
||||
{removingRoadmapId === resourceId && (
|
||||
<span className="text-xs">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={() => onRemove(resourceId)}
|
||||
className="mx-0.5 text-red-500 underline underline-offset-1"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setRemovingRoadmapId('')}
|
||||
className="text-red-500 underline underline-offset-1"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<button
|
||||
onClick={() => setIsAddingRoadmap(true)}
|
||||
className="group flex min-h-[110px] flex-col items-center justify-center rounded-md border border-dashed border-gray-300 transition-colors hover:border-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
<img
|
||||
alt="add"
|
||||
src={PlusIcon.src}
|
||||
className="mb-1 h-6 w-6 opacity-20 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 transition-colors focus:outline-none group-hover:text-black">
|
||||
Add Roadmap
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
39
src/components/TeamRoadmaps/PickRoadmapOptionModal.tsx
Normal file
39
src/components/TeamRoadmaps/PickRoadmapOptionModal.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { Modal } from '../Modal';
|
||||
import { Map, Shapes } from 'lucide-react';
|
||||
|
||||
type PickRoadmapOptionModalProps = {
|
||||
onClose: () => void;
|
||||
showDefaultRoadmapsModal: () => void;
|
||||
showCreateCustomRoadmapModal: () => void;
|
||||
};
|
||||
|
||||
export function PickRoadmapOptionModal(props: PickRoadmapOptionModalProps) {
|
||||
const { onClose, showDefaultRoadmapsModal, showCreateCustomRoadmapModal } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} bodyClassName="p-4">
|
||||
<h2 className="mb-0.5 text-left text-2xl font-semibold">Pick an Option</h2>
|
||||
<p className="text-left text-sm text-gray-500 mb-4">
|
||||
Choose from default roadmaps or create from scratch.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="text-base flex items-center rounded-md border border-gray-300 p-2 px-4 text-left font-medium hover:bg-gray-100"
|
||||
onClick={showDefaultRoadmapsModal}
|
||||
>
|
||||
<Map className="mr-2 inline-block" size={20} />
|
||||
Use a Default Roadmap
|
||||
</button>
|
||||
<button
|
||||
className="text-base flex items-center rounded-md border border-gray-300 p-2 px-4 text-left font-medium hover:bg-gray-100"
|
||||
onClick={showCreateCustomRoadmapModal}
|
||||
>
|
||||
<Shapes className="mr-2 inline-block" size={20} />
|
||||
Create from Scratch
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
93
src/components/TeamRoadmapsList/RoadmapActionDropdown.tsx
Normal file
93
src/components/TeamRoadmapsList/RoadmapActionDropdown.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import MoreIcon from '../../icons/more-vertical.svg';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
|
||||
|
||||
type RoadmapActionDropdownProps = {
|
||||
onDelete?: () => void;
|
||||
onCustomize?: () => void;
|
||||
onUpdateSharing?: () => void;
|
||||
};
|
||||
|
||||
export function RoadmapActionDropdown(props: RoadmapActionDropdownProps) {
|
||||
const { onDelete, onUpdateSharing, onCustomize } = props;
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useOutsideClick(menuRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
disabled={false}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex"
|
||||
>
|
||||
<img alt="menu" src={MoreIcon.src} className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
disabled={false}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none sm:hidden"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
Options
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="align-right absolute right-auto top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md sm:right-0"
|
||||
>
|
||||
<ul>
|
||||
{onUpdateSharing && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onUpdateSharing();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Lock size={14} className="mr-2" />
|
||||
Sharing
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onCustomize && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onCustomize();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Shapes size={14} className="mr-2" />
|
||||
Customize
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onDelete && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onDelete();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
636
src/components/TeamRoadmapsList/TeamRoadmaps.tsx
Normal file
636
src/components/TeamRoadmapsList/TeamRoadmaps.tsx
Normal file
@ -0,0 +1,636 @@
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { TeamDocument } from '../CreateTeam/CreateTeamForm';
|
||||
import type { TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $canManageCurrentTeam } from '../../stores/team';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { SelectRoadmapModal } from '../CreateTeam/SelectRoadmapModal';
|
||||
import { PickRoadmapOptionModal } from '../TeamRoadmaps/PickRoadmapOptionModal';
|
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import {
|
||||
ExternalLink,
|
||||
Globe,
|
||||
LockIcon,
|
||||
type LucideIcon,
|
||||
Package,
|
||||
PackageMinus,
|
||||
PenSquare,
|
||||
Shapes,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { RoadmapActionDropdown } from './RoadmapActionDropdown';
|
||||
import { UpdateTeamResourceModal } from '../CreateTeam/UpdateTeamResourceModal';
|
||||
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
|
||||
|
||||
export function TeamRoadmaps() {
|
||||
const { t: teamId } = getUrlParams();
|
||||
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPickingOptions, setIsPickingOptions] = useState(false);
|
||||
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false);
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [team, setTeam] = useState<TeamDocument>();
|
||||
const [teamResources, setTeamResources] = useState<TeamResourceConfig>([]);
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
const [selectedResource, setSelectedResource] = useState<
|
||||
TeamResourceConfig[0] | null
|
||||
>(null);
|
||||
|
||||
async function loadAllRoadmaps() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allRoadmaps = response
|
||||
.filter((page) => page.group === 'Roadmaps')
|
||||
.sort((a, b) => {
|
||||
if (a.title === 'Android') return 1;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
setAllRoadmaps(allRoadmaps);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function loadTeam(teamIdToFetch: string) {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error('Error loading team');
|
||||
window.location.href = '/account';
|
||||
return;
|
||||
}
|
||||
|
||||
setTeam(response);
|
||||
}
|
||||
|
||||
async function loadTeamResourceConfig(teamId: string) {
|
||||
const { error, response } = await httpGet<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
|
||||
);
|
||||
if (error || !Array.isArray(response)) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResources(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Promise.all([
|
||||
loadTeam(teamId),
|
||||
loadTeamResourceConfig(teamId),
|
||||
loadAllRoadmaps(),
|
||||
]).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
async function deleteResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading('Deleting roadmap');
|
||||
pageProgressMessage.set(`Deleting roadmap from team`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Roadmap removed');
|
||||
setTeamResources(response);
|
||||
}
|
||||
|
||||
async function onAdd(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading('Adding roadmap');
|
||||
pageProgressMessage.set('Adding roadmap');
|
||||
setIsLoading(true);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResources(response);
|
||||
toast.success('Roadmap added');
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
pageProgressMessage.set('Removing roadmap');
|
||||
|
||||
deleteResource(resourceId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleCustomRoadmapCreated(event: Event) {
|
||||
const { roadmapId } = (event as CustomEvent)?.detail;
|
||||
if (!roadmapId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadAllRoadmaps().finally(() => {});
|
||||
onAdd(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
window.addEventListener(
|
||||
'custom-roadmap-created',
|
||||
handleCustomRoadmapCreated
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'custom-roadmap-created',
|
||||
handleCustomRoadmapCreated
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pickRoadmapOptionModal = isPickingOptions && (
|
||||
<PickRoadmapOptionModal
|
||||
onClose={() => setIsPickingOptions(false)}
|
||||
showDefaultRoadmapsModal={() => {
|
||||
setIsAddingRoadmap(true);
|
||||
setIsPickingOptions(false);
|
||||
}}
|
||||
showCreateCustomRoadmapModal={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
setIsPickingOptions(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const addRoadmapModal = isAddingRoadmap && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setIsAddingRoadmap(false)}
|
||||
teamResourceConfig={teamResources}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId: string) => {
|
||||
onAdd(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
onRoadmapRemove={(roadmapId: string) => {
|
||||
if (confirm('Are you sure you want to remove this roadmap?')) {
|
||||
onRemove(roadmapId).finally(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const createRoadmapModal = isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
teamId={teamId}
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
onCreated={() => {
|
||||
loadTeamResourceConfig(teamId).finally(() => null);
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const placeholderRoadmaps = teamResources.filter(
|
||||
(c: TeamResourceConfig[0]) => c.isCustomResource && !c.topics
|
||||
);
|
||||
const customRoadmaps = teamResources.filter(
|
||||
(c: TeamResourceConfig[0]) => c.isCustomResource && c.topics
|
||||
);
|
||||
const defaultRoadmaps = teamResources.filter(
|
||||
(c: TeamResourceConfig[0]) => !c.isCustomResource
|
||||
);
|
||||
|
||||
const hasRoadmaps =
|
||||
customRoadmaps.length > 0 ||
|
||||
defaultRoadmaps.length > 0 ||
|
||||
(placeholderRoadmaps.length > 0 && canManageCurrentTeam);
|
||||
if (!hasRoadmaps && !isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 py-20">
|
||||
{pickRoadmapOptionModal}
|
||||
{addRoadmapModal}
|
||||
{createRoadmapModal}
|
||||
|
||||
<img
|
||||
alt="roadmap"
|
||||
src={RoadmapIcon.src}
|
||||
className="mb-4 h-24 w-24 opacity-10"
|
||||
/>
|
||||
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
|
||||
<p className="text-base text-gray-500">
|
||||
{canManageCurrentTeam
|
||||
? 'Add a roadmap to start tracking your team'
|
||||
: 'Ask your team admin to add some roadmaps'}
|
||||
</p>
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<button
|
||||
className="mt-4 rounded-lg bg-black px-4 py-2 font-medium text-white hover:bg-gray-900"
|
||||
onClick={() => setIsPickingOptions(true)}
|
||||
>
|
||||
Add roadmap
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const customizeRoadmapModal = changingRoadmapId && (
|
||||
<UpdateTeamResourceModal
|
||||
onClose={() => setChangingRoadmapId('')}
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={team?._id!}
|
||||
setTeamResourceConfig={setTeamResources}
|
||||
defaultRemovedItems={
|
||||
defaultRoadmaps.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const shareSettingsModal = selectedResource && (
|
||||
<ShareOptionsModal
|
||||
visibility={selectedResource.visibility!}
|
||||
sharedTeamMemberIds={selectedResource.sharedTeamMemberIds!}
|
||||
sharedFriendIds={selectedResource.sharedFriendIds!}
|
||||
teamId={teamId}
|
||||
roadmapId={selectedResource.resourceId}
|
||||
onShareSettingsUpdate={(shareSettings) => {
|
||||
setTeamResources((prev) => {
|
||||
return prev.map((c) => {
|
||||
if (c.resourceId !== selectedResource.resourceId) {
|
||||
return c;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
...shareSettings,
|
||||
};
|
||||
});
|
||||
});
|
||||
}}
|
||||
onClose={() => setSelectedResource(null)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{pickRoadmapOptionModal}
|
||||
{addRoadmapModal}
|
||||
{createRoadmapModal}
|
||||
{customizeRoadmapModal}
|
||||
{shareSettingsModal}
|
||||
|
||||
{canManageCurrentTeam && placeholderRoadmaps.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400">
|
||||
<span className="flex">Placeholder Roadmaps</span>
|
||||
<span className="normal-case">
|
||||
Total {placeholderRoadmaps.length} roadmap(s)
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y rounded-md border">
|
||||
{placeholderRoadmaps.map(
|
||||
(resourceConfig: TeamResourceConfig[0]) => {
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_173px]"
|
||||
key={resourceConfig.resourceId}
|
||||
>
|
||||
<div className="mb-3 grid sm:mb-0">
|
||||
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
|
||||
{resourceConfig.title}
|
||||
</p>
|
||||
<span className="text-xs italic leading-none text-gray-400/60">
|
||||
Placeholder roadmap
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<div className="flex items-center justify-start gap-2 sm:justify-end">
|
||||
<RoadmapActionDropdown
|
||||
onUpdateSharing={() => {
|
||||
setSelectedResource(resourceConfig);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (
|
||||
confirm(
|
||||
'Are you sure you want to remove this roadmap?'
|
||||
)
|
||||
) {
|
||||
onRemove(resourceConfig.resourceId).finally(
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
|
||||
resourceConfig.resourceId
|
||||
}`}
|
||||
className={
|
||||
'flex gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
|
||||
}
|
||||
target={'_blank'}
|
||||
>
|
||||
<PenSquare className="inline-block h-4 w-4" />
|
||||
Create Roadmap
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customRoadmaps.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400">
|
||||
<span className="flex">Custom Roadmaps</span>
|
||||
<span className="normal-case">
|
||||
Total {customRoadmaps.length} roadmap(s)
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y rounded-md border">
|
||||
{customRoadmaps.map((resourceConfig: TeamResourceConfig[0]) => {
|
||||
const editorLink = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
|
||||
resourceConfig.resourceId
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_110px]"
|
||||
key={resourceConfig.resourceId}
|
||||
>
|
||||
<div className="mb-3 grid grid-cols-1 sm:mb-0">
|
||||
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
|
||||
{resourceConfig.title}
|
||||
</p>
|
||||
<span className="flex items-center text-xs leading-none text-gray-400">
|
||||
<VisibilityBadge
|
||||
visibility={resourceConfig.visibility!}
|
||||
sharedTeamMemberIds={resourceConfig.sharedTeamMemberIds}
|
||||
sharedFriendIds={resourceConfig.sharedFriendIds}
|
||||
/>
|
||||
<span className="mx-2 font-semibold">·</span>
|
||||
<Shapes size={16} className="mr-1 inline-block h-4 w-4" />
|
||||
{resourceConfig.topics} topic
|
||||
</span>
|
||||
</div>
|
||||
<div className="mr-1 flex items-center justify-start sm:justify-end">
|
||||
{canManageCurrentTeam && (
|
||||
<RoadmapActionDropdown
|
||||
onUpdateSharing={() => {
|
||||
setSelectedResource(resourceConfig);
|
||||
}}
|
||||
onCustomize={() => {
|
||||
window.open(editorLink, '_blank');
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (
|
||||
confirm(
|
||||
'Are you sure you want to remove this roadmap?'
|
||||
)
|
||||
) {
|
||||
onRemove(resourceConfig.resourceId).finally(
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`/r?id=${resourceConfig.resourceId}`}
|
||||
className={
|
||||
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
|
||||
}
|
||||
target={'_blank'}
|
||||
>
|
||||
<ExternalLink className="inline-block h-4 w-4" />
|
||||
Visit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{defaultRoadmaps.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400">
|
||||
<span className="flex">Default Roadmaps</span>
|
||||
<span className="normal-case">
|
||||
Total {defaultRoadmaps.length} roadmap(s)
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y rounded-md border">
|
||||
{defaultRoadmaps.map((resourceConfig: TeamResourceConfig[0]) => {
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-1 p-3 sm:grid-cols-[auto_110px]"
|
||||
key={resourceConfig.resourceId}
|
||||
>
|
||||
<div className="mb-3 grid grid-cols-1 sm:mb-0">
|
||||
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
|
||||
{resourceConfig.title}
|
||||
</p>
|
||||
<span className="flex items-center text-xs leading-none text-gray-400">
|
||||
{resourceConfig?.removed?.length > 0 && (
|
||||
<>
|
||||
<PackageMinus
|
||||
size={16}
|
||||
className="mr-1 inline-block h-4 w-4"
|
||||
/>
|
||||
{resourceConfig.removed.length} topics removed
|
||||
</>
|
||||
)}
|
||||
|
||||
{!resourceConfig?.removed?.length && (
|
||||
<>
|
||||
<Package
|
||||
size={16}
|
||||
className="mr-1 inline-block h-4 w-4"
|
||||
/>
|
||||
No changes made
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mr-1 flex items-center justify-start sm:justify-end">
|
||||
{canManageCurrentTeam && (
|
||||
<RoadmapActionDropdown
|
||||
onCustomize={() => {
|
||||
setChangingRoadmapId(resourceConfig.resourceId);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (
|
||||
confirm(
|
||||
'Are you sure you want to remove this roadmap?'
|
||||
)
|
||||
) {
|
||||
onRemove(resourceConfig.resourceId).finally(
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`/${resourceConfig.resourceId}`}
|
||||
className={
|
||||
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
|
||||
}
|
||||
target={'_blank'}
|
||||
>
|
||||
<ExternalLink className="inline-block h-4 w-4" />
|
||||
Visit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<div className="mt-5">
|
||||
<button
|
||||
className="block w-full rounded-md border border-dashed border-gray-300 py-2 text-sm transition-colors hover:border-gray-600 hover:bg-gray-50 focus:outline-0"
|
||||
onClick={() => setIsPickingOptions(true)}
|
||||
>
|
||||
+ Add new Roadmap
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type VisibilityLabelProps = {
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
sharedTeamMemberIds?: string[];
|
||||
sharedFriendIds?: string[];
|
||||
};
|
||||
|
||||
const visibilityDetails: Record<
|
||||
AllowedRoadmapVisibility,
|
||||
{
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
> = {
|
||||
public: {
|
||||
icon: Globe,
|
||||
label: 'Public',
|
||||
},
|
||||
me: {
|
||||
icon: LockIcon,
|
||||
label: 'Only me',
|
||||
},
|
||||
team: {
|
||||
icon: Users,
|
||||
label: 'Team Member(s)',
|
||||
},
|
||||
friends: {
|
||||
icon: Users,
|
||||
label: 'Friend(s)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function VisibilityBadge(props: VisibilityLabelProps) {
|
||||
const { visibility, sharedTeamMemberIds = [], sharedFriendIds = [] } = props;
|
||||
|
||||
const { label, icon: Icon } = visibilityDetails[visibility];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`}
|
||||
>
|
||||
<Icon className="inline-block h-3 w-3" />
|
||||
<div className="flex items-center">
|
||||
{visibility === 'team' && sharedTeamMemberIds?.length > 0 && (
|
||||
<span className="mr-1">{sharedTeamMemberIds.length}</span>
|
||||
)}
|
||||
{visibility === 'friends' && sharedFriendIds?.length > 0 && (
|
||||
<span className="mr-1">{sharedFriendIds.length}</span>
|
||||
)}
|
||||
{label}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
@ -1,21 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import ChevronDown from '../icons/dropdown.svg';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet } from '../lib/http';
|
||||
import { useTeamId } from '../hooks/use-team-id';
|
||||
import { useAuth } from '../hooks/use-auth';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
|
||||
type TeamListResponse = TeamDocument[];
|
||||
import { type UserTeamItem } from './TeamDropdown/TeamDropdown';
|
||||
|
||||
export function TeamsList() {
|
||||
const [teamList, setTeamList] = useState<TeamDocument[]>([]);
|
||||
const [teamList, setTeamList] = useState<UserTeamItem[]>([]);
|
||||
const user = useAuth();
|
||||
const toast = useToast();
|
||||
async function getAllTeam() {
|
||||
const { response, error } = await httpGet<TeamListResponse>(
|
||||
const { response, error } = await httpGet<UserTeamItem[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
);
|
||||
if (error || !response) {
|
||||
@ -64,30 +59,39 @@ export function TeamsList() {
|
||||
<span>→</span>
|
||||
</a>
|
||||
</li>
|
||||
{teamList.map((team) => (
|
||||
<li key={team._id}>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50"
|
||||
href={`/team/progress?t=${team._id}`}
|
||||
>
|
||||
<span className="flex flex-grow items-center gap-2">
|
||||
<img
|
||||
src={
|
||||
team.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
team.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={team.name || ''}
|
||||
className="h-6 w-6 rounded-full"
|
||||
/>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</span>
|
||||
<span>→</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{teamList.map((team) => {
|
||||
let pageLink = '';
|
||||
if (team.status === 'invited') {
|
||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||
} else if (team.status === 'joined') {
|
||||
pageLink = `/team/progress?t=${team._id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={team._id}>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50"
|
||||
href={pageLink}
|
||||
>
|
||||
<span className="flex flex-grow items-center gap-2">
|
||||
<img
|
||||
src={
|
||||
team.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
team.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={team.name || ''}
|
||||
className="h-6 w-6 rounded-full"
|
||||
/>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</span>
|
||||
<span>→</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<a
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
|
@ -53,7 +53,7 @@ export function Toaster(props: Props) {
|
||||
onClick={() => {
|
||||
$toastMessage.set(undefined);
|
||||
}}
|
||||
className={`fixed bottom-5 left-1/2 z-50 min-w-[375px] max-w-[375px] animate-fade-slide-up sm:min-w-[auto]`}
|
||||
className={`fixed bottom-5 left-1/2 z-[9999] min-w-[375px] max-w-[375px] animate-fade-slide-up sm:min-w-[auto]`}
|
||||
>
|
||||
<div
|
||||
className={`flex -translate-x-1/2 transform cursor-pointer items-center gap-2 rounded-md border border-gray-200 bg-white py-3 pl-4 pr-5 text-black shadow-md hover:bg-gray-50`}
|
||||
|
61
src/components/Tooltip.tsx
Normal file
61
src/components/Tooltip.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type TooltipProps = {
|
||||
children: ReactNode;
|
||||
position?:
|
||||
| 'right-center'
|
||||
| 'right-top'
|
||||
| 'right-bottom'
|
||||
| 'left-center'
|
||||
| 'left-top'
|
||||
| 'left-bottom'
|
||||
| 'top-center'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-center'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
};
|
||||
|
||||
export function Tooltip(props: TooltipProps) {
|
||||
const { children, position = 'right-center' } = props;
|
||||
|
||||
let positionClass = '';
|
||||
if (position === 'right-center') {
|
||||
positionClass = 'left-full top-1/2 -translate-y-1/2 translate-x-1 ';
|
||||
} else if (position === 'top-center') {
|
||||
positionClass = 'bottom-full left-1/2 -translate-x-1/2 -translate-y-0.5';
|
||||
} else if (position === 'bottom-center') {
|
||||
positionClass = 'top-full left-1/2 -translate-x-1/2 translate-y-0.5';
|
||||
} else if (position === 'left-center') {
|
||||
positionClass = 'right-full top-1/2 -translate-y-1/2 -translate-x-1';
|
||||
} else if (position === 'right-top') {
|
||||
positionClass = 'left-full top-0';
|
||||
} else if (position === 'right-bottom') {
|
||||
positionClass = 'left-full bottom-0';
|
||||
} else if (position === 'left-top') {
|
||||
positionClass = 'right-full top-0';
|
||||
} else if (position === 'left-bottom') {
|
||||
positionClass = 'right-full bottom-0';
|
||||
} else if (position === 'top-left') {
|
||||
positionClass = 'bottom-full left-0';
|
||||
} else if (position === 'top-right') {
|
||||
positionClass = 'bottom-full right-0';
|
||||
} else if (position === 'bottom-left') {
|
||||
positionClass = 'top-full left-0';
|
||||
} else if (position === 'bottom-right') {
|
||||
positionClass = 'top-full right-0';
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'pointer-events-none absolute z-10 block w-max transform rounded-md bg-gray-900 px-2 py-1 text-sm font-medium text-white opacity-0 shadow-sm duration-100 group-hover:opacity-100',
|
||||
positionClass
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
@ -20,16 +20,42 @@ import { TopicProgressButton } from './TopicProgressButton';
|
||||
import { ContributionForm } from './ContributionForm';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type {
|
||||
AllowedLinkTypes,
|
||||
RoadmapContentDocument,
|
||||
} from '../CustomRoadmap/CustomRoadmap';
|
||||
import { markdownToHtml } from '../../lib/markdown';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { Ban, FileText } from 'lucide-react';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
|
||||
type TopicDetailProps = {
|
||||
canSubmitContribution: boolean;
|
||||
};
|
||||
|
||||
const linkTypes: Record<AllowedLinkTypes, string> = {
|
||||
article: 'bg-yellow-200',
|
||||
course: 'bg-green-200',
|
||||
opensource: 'bg-blue-200',
|
||||
podcast: 'bg-purple-200',
|
||||
video: 'bg-pink-200',
|
||||
website: 'bg-red-200',
|
||||
};
|
||||
|
||||
export function TopicDetail(props: TopicDetailProps) {
|
||||
const { canSubmitContribution } = props;
|
||||
|
||||
export function TopicDetail() {
|
||||
const [contributionAlertMessage, setContributionAlertMessage] = useState('');
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isContributing, setIsContributing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [topicHtml, setTopicHtml] = useState('');
|
||||
const [topicTitle, setTopicTitle] = useState('');
|
||||
const [links, setLinks] = useState<RoadmapContentDocument['links']>([]);
|
||||
const toast = useToast();
|
||||
|
||||
const { secret } = getUrlParams() as { secret: string };
|
||||
const isGuest = useMemo(() => !isLoggedIn(), []);
|
||||
const topicRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -89,7 +115,8 @@ export function TopicDetail() {
|
||||
});
|
||||
|
||||
// Load the topic detail when the topic detail is active
|
||||
useLoadTopic(({ topicId, resourceType, resourceId }) => {
|
||||
useLoadTopic(({ topicId, resourceType, resourceId, isCustomResource }) => {
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
setIsActive(true);
|
||||
sponsorHidden.set(true);
|
||||
@ -100,30 +127,53 @@ export function TopicDetail() {
|
||||
setResourceId(resourceId);
|
||||
|
||||
const topicPartial = topicId.replaceAll(':', '/');
|
||||
const topicUrl =
|
||||
let topicUrl =
|
||||
resourceType === 'roadmap'
|
||||
? `/${resourceId}/${topicPartial}`
|
||||
: `/best-practices/${resourceId}/${topicPartial}`;
|
||||
|
||||
httpGet<string>(
|
||||
if (isCustomResource) {
|
||||
topicUrl = `${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-node-content/${resourceId}/${topicId}${
|
||||
secret ? `?secret=${secret}` : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
httpGet<string | RoadmapContentDocument>(
|
||||
topicUrl,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
},
|
||||
...(!isCustomResource && {
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
.then(({ response }) => {
|
||||
if (!response) {
|
||||
setError('Topic not found.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// It's full HTML with page body, head etc.
|
||||
// We only need the inner HTML of the #main-content
|
||||
const node = new DOMParser().parseFromString(response, 'text/html');
|
||||
const topicHtml = node?.getElementById('main-content')?.outerHTML || '';
|
||||
let topicHtml = '';
|
||||
if (!isCustomResource) {
|
||||
// It's full HTML with page body, head etc.
|
||||
// We only need the inner HTML of the #main-content
|
||||
const node = new DOMParser().parseFromString(
|
||||
response as string,
|
||||
'text/html'
|
||||
);
|
||||
topicHtml = node?.getElementById('main-content')?.outerHTML || '';
|
||||
} else {
|
||||
setLinks((response as RoadmapContentDocument)?.links || []);
|
||||
setTopicTitle((response as RoadmapContentDocument)?.title || '');
|
||||
topicHtml = markdownToHtml(
|
||||
(response as RoadmapContentDocument)?.description || '',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setTopicHtml(topicHtml);
|
||||
@ -138,8 +188,10 @@ export function TopicDetail() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasContent = topicHtml?.length > 0 || links?.length > 0 || topicTitle;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={'relative z-50'}>
|
||||
<div
|
||||
ref={topicRef}
|
||||
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6"
|
||||
@ -197,35 +249,96 @@ export function TopicDetail() {
|
||||
</div>
|
||||
|
||||
{/* Topic Content */}
|
||||
<div
|
||||
id="topic-content"
|
||||
className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5"
|
||||
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
||||
></div>
|
||||
{hasContent ? (
|
||||
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
|
||||
{topicTitle && <h1>{topicTitle}</h1>}
|
||||
<div
|
||||
id="topic-content"
|
||||
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[calc(100%-38px)] flex-col items-center justify-center">
|
||||
<FileText className="h-16 w-16 text-gray-300" />
|
||||
<p className="mt-2 text-lg font-medium text-gray-500">
|
||||
Empty Content
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<ul className="mt-6 space-y-1">
|
||||
{links.map((link) => {
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
className="font-medium underline"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mr-2 inline-block rounded px-1.5 py-1 text-xs uppercase no-underline',
|
||||
linkTypes[link.type]
|
||||
)}
|
||||
>
|
||||
{link.type.charAt(0).toUpperCase() +
|
||||
link.type.slice(1)}
|
||||
</span>
|
||||
{link.title}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Contribution */}
|
||||
<div className="mt-8 flex-1 border-t">
|
||||
<p className="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
|
||||
Help others learn by submitting links to learn more about this
|
||||
topic{' '}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isGuest) {
|
||||
setIsActive(false);
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
{canSubmitContribution && (
|
||||
<div className="mt-8 flex-1 border-t">
|
||||
<p className="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
|
||||
Help others learn by submitting links to learn more about this
|
||||
topic{' '}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isGuest) {
|
||||
setIsActive(false);
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsContributing(true);
|
||||
}}
|
||||
disabled={!!contributionAlertMessage}
|
||||
className="block w-full rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
|
||||
>
|
||||
{contributionAlertMessage
|
||||
? contributionAlertMessage
|
||||
: 'Submit a Link'}
|
||||
</button>
|
||||
setIsContributing(true);
|
||||
}}
|
||||
disabled={!!contributionAlertMessage}
|
||||
className="block w-full rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
|
||||
>
|
||||
{contributionAlertMessage
|
||||
? contributionAlertMessage
|
||||
: 'Submit a Link'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{!isContributing && !isLoading && error && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
id="close-topic"
|
||||
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
|
||||
onClick={() => {
|
||||
setIsActive(false);
|
||||
setIsContributing(false);
|
||||
}}
|
||||
>
|
||||
<img alt="Close" className="h-5 w-5" src={CloseIcon.src} />
|
||||
</button>
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<Ban className="h-16 w-16 text-red-500" />
|
||||
<p className="mt-2 text-lg font-medium text-red-500">{error}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
@ -3,7 +3,7 @@ import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { ShareIcon } from '../ReactIcons/ShareIcon';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type ProgressShareButtonProps = {
|
||||
resourceId: string;
|
||||
@ -11,6 +11,7 @@ type ProgressShareButtonProps = {
|
||||
className?: string;
|
||||
shareIconClassName?: string;
|
||||
checkIconClassName?: string;
|
||||
isCustomResource?: boolean;
|
||||
};
|
||||
export function ProgressShareButton(props: ProgressShareButtonProps) {
|
||||
const {
|
||||
@ -19,6 +20,7 @@ export function ProgressShareButton(props: ProgressShareButtonProps) {
|
||||
className,
|
||||
shareIconClassName,
|
||||
checkIconClassName,
|
||||
isCustomResource,
|
||||
} = props;
|
||||
|
||||
const user = useAuth();
|
||||
@ -30,10 +32,13 @@ export function ProgressShareButton(props: ProgressShareButtonProps) {
|
||||
isDev ? 'http://localhost:3000' : 'https://roadmap.sh'
|
||||
);
|
||||
|
||||
if (resourceType === 'roadmap') {
|
||||
if (resourceType === 'roadmap' && !isCustomResource) {
|
||||
newUrl.pathname = `/${resourceId}`;
|
||||
} else {
|
||||
} else if (resourceType === 'best-practice' && !isCustomResource) {
|
||||
newUrl.pathname = `/best-practices/${resourceId}`;
|
||||
} else {
|
||||
newUrl.pathname = `/r`;
|
||||
newUrl.searchParams.set('id', resourceId || '');
|
||||
}
|
||||
|
||||
newUrl.searchParams.set('s', user?.id || '');
|
||||
@ -46,9 +51,11 @@ export function ProgressShareButton(props: ProgressShareButtonProps) {
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center gap-1 text-sm font-medium ${
|
||||
isCopied ? 'text-green-500' : 'text-gray-500 hover:text-black'
|
||||
} ${className}`}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-70',
|
||||
isCopied ? 'text-green-500' : 'text-gray-500 hover:text-black',
|
||||
className
|
||||
)}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{isCopied ? (
|
||||
|
@ -11,12 +11,14 @@ import { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
||||
import { renderFlowJSON } from '../../../renderer/renderer';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
userId?: string;
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
onClose?: () => void;
|
||||
isCustomResource?: boolean;
|
||||
};
|
||||
|
||||
type UserProgressResponse = {
|
||||
@ -38,6 +40,7 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
resourceType,
|
||||
userId: propUserId,
|
||||
onClose: onModalClose,
|
||||
isCustomResource,
|
||||
} = props;
|
||||
|
||||
const { s: userId = propUserId } = getUrlParams();
|
||||
@ -66,6 +69,12 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||
}
|
||||
|
||||
if (isCustomResource) {
|
||||
resourceJsonUrl = `${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-roadmap/${resourceId}`;
|
||||
}
|
||||
|
||||
async function getUserProgress(
|
||||
userId: string,
|
||||
resourceType: string,
|
||||
@ -92,6 +101,12 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
throw error || new Error('Something went wrong. Please try again!');
|
||||
}
|
||||
|
||||
if (isCustomResource) {
|
||||
return await renderFlowJSON({
|
||||
nodes: roadmapJson?.nodes || [],
|
||||
edges: roadmapJson?.edges || [],
|
||||
});
|
||||
}
|
||||
return await wireframeJSONToSVG(roadmapJson, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
@ -165,6 +180,14 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
el.removeAttribute('data-group-id');
|
||||
});
|
||||
|
||||
svg.querySelectorAll('[data-node-id]').forEach((el) => {
|
||||
el.removeAttribute('data-node-id');
|
||||
});
|
||||
|
||||
svg.querySelectorAll('[data-type]').forEach((el) => {
|
||||
el.removeAttribute('data-type');
|
||||
});
|
||||
|
||||
setResourceSvg(svg);
|
||||
setProgressResponse(user);
|
||||
})
|
||||
|
File diff suppressed because it is too large
Load Diff
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@ -4,6 +4,7 @@ interface ImportMetaEnv {
|
||||
GITHUB_SHA: string;
|
||||
PUBLIC_API_URL: string;
|
||||
PUBLIC_AVATAR_BASE_URL: string;
|
||||
PUBLIC_EDITOR_APP_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
@ -5,26 +5,35 @@ type CallbackType = (data: {
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
topicId: string;
|
||||
isCustomResource: boolean;
|
||||
}) => void;
|
||||
|
||||
export function useLoadTopic(callback: CallbackType) {
|
||||
useEffect(() => {
|
||||
function handleTopicClick(e: any) {
|
||||
const { resourceType, resourceId, topicId } = e.detail;
|
||||
const {
|
||||
resourceType,
|
||||
resourceId,
|
||||
topicId,
|
||||
isCustomResource = false,
|
||||
} = e.detail;
|
||||
|
||||
callback({
|
||||
resourceType,
|
||||
resourceId,
|
||||
topicId,
|
||||
isCustomResource,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener(`roadmap.topic.click`, handleTopicClick);
|
||||
window.addEventListener(`best-practice.topic.click`, handleTopicClick);
|
||||
window.addEventListener(`roadmap.node.click`, handleTopicClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(`roadmap.topic.click`, handleTopicClick);
|
||||
window.removeEventListener(`best-practice.topic.click`, handleTopicClick);
|
||||
window.removeEventListener(`roadmap.node.click`, handleTopicClick);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
---
|
||||
import BaseLayout, { Props as BaseLayoutProps } from './BaseLayout.astro';
|
||||
import BaseLayout, { type Props as BaseLayoutProps } from './BaseLayout.astro';
|
||||
|
||||
export interface Props extends BaseLayoutProps {}
|
||||
|
||||
|
@ -82,7 +82,10 @@ const gaPageIdentifier = Astro.url.pathname
|
||||
|
||||
<meta property='og:image:width' content='1200' />
|
||||
<meta property='og:image:height' content='630' />
|
||||
<meta property='og:image' content={ogImageUrl || 'https://roadmap.sh/images/og-img.png'} />
|
||||
<meta
|
||||
property='og:image'
|
||||
content={ogImageUrl || 'https://roadmap.sh/images/og-img.png'}
|
||||
/>
|
||||
<meta property='og:image:alt' content='roadmap.sh' />
|
||||
<meta property='og:site_name' content='roadmap.sh' />
|
||||
<meta property='og:title' content={title} />
|
||||
@ -153,10 +156,11 @@ const gaPageIdentifier = Astro.url.pathname
|
||||
</slot>
|
||||
|
||||
<Authenticator />
|
||||
<slot name="login-popup">
|
||||
<slot name='login-popup'>
|
||||
<LoginPopup />
|
||||
</slot>
|
||||
<Toaster client:only="react" />
|
||||
|
||||
<Toaster client:only='react' />
|
||||
<CommandMenu client:idle />
|
||||
<PageProgress initialMessage={initialLoadingMessage} client:idle />
|
||||
<PageSponsor
|
||||
|
6
src/lib/classname.ts
Normal file
6
src/lib/classname.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
@ -71,7 +71,7 @@ export async function httpCall<
|
||||
}
|
||||
|
||||
if (data.status === 403) {
|
||||
window.location.href = '/account';
|
||||
// window.location.href = '/account'; // @fixme redirect option should be configurable
|
||||
return { response: undefined, error: data as ErrorType };
|
||||
}
|
||||
|
||||
|
@ -21,3 +21,13 @@ export function isLoggedIn() {
|
||||
|
||||
return !!token;
|
||||
}
|
||||
|
||||
export function getUser() {
|
||||
const token = Cookies.get(TOKEN_COOKIE_NAME);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeToken(token);
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { httpGet, httpPost } from './http';
|
||||
import { TOKEN_COOKIE_NAME } from './jwt';
|
||||
import { TOKEN_COOKIE_NAME, getUser } from './jwt';
|
||||
// @ts-ignore
|
||||
import Element = astroHTML.JSX.Element;
|
||||
|
||||
export type ResourceType = 'roadmap' | 'best-practice';
|
||||
@ -92,8 +93,9 @@ export async function getResourceProgress(
|
||||
};
|
||||
}
|
||||
|
||||
const progressKey = `${resourceType}-${resourceId}-progress`;
|
||||
const isFavoriteKey = `${resourceType}-${resourceId}-favorite`;
|
||||
const userId = getUser()?.id;
|
||||
const progressKey = `${resourceType}-${resourceId}-${userId}-progress`;
|
||||
const isFavoriteKey = `${resourceType}-${resourceId}-${userId}-favorite`;
|
||||
|
||||
const rawIsFavorite = localStorage.getItem(isFavoriteKey);
|
||||
const isFavorite = JSON.parse(rawIsFavorite || '0') === 1;
|
||||
@ -175,8 +177,9 @@ export function setResourceProgress(
|
||||
learning: string[],
|
||||
skipped: string[]
|
||||
): void {
|
||||
const userId = getUser()?.id;
|
||||
localStorage.setItem(
|
||||
`${resourceType}-${resourceId}-progress`,
|
||||
`${resourceType}-${resourceId}-${userId}-progress`,
|
||||
JSON.stringify({
|
||||
done,
|
||||
learning,
|
||||
@ -205,19 +208,16 @@ export function topicSelectorAll(
|
||||
}
|
||||
});
|
||||
|
||||
// Elements with exact match of the topic id
|
||||
parentElement
|
||||
.querySelectorAll(`[data-group-id="${topicId}"]`)
|
||||
.forEach((element) => {
|
||||
matchingElements.push(element);
|
||||
});
|
||||
|
||||
// Matching "check:XXXX" box of the topic
|
||||
parentElement
|
||||
.querySelectorAll(`[data-group-id="check:${topicId}"]`)
|
||||
.forEach((element) => {
|
||||
matchingElements.push(element);
|
||||
});
|
||||
getMatchingElements(
|
||||
[
|
||||
`[data-group-id="${topicId}"]`, // Elements with exact match of the topic id
|
||||
`[data-group-id="check:${topicId}"]`, // Matching "check:XXXX" box of the topic
|
||||
`[data-node-id="${topicId}"]`, // Matching custom roadmap nodes
|
||||
],
|
||||
parentElement
|
||||
).forEach((element) => {
|
||||
matchingElements.push(element);
|
||||
});
|
||||
|
||||
return matchingElements;
|
||||
}
|
||||
@ -253,8 +253,12 @@ export function renderTopicProgress(
|
||||
}
|
||||
|
||||
export function clearResourceProgress() {
|
||||
const clickableElements = document.querySelectorAll('.clickable-group');
|
||||
for (const clickableElement of clickableElements) {
|
||||
const matchingElements = getMatchingElements([
|
||||
'.clickable-group',
|
||||
'[data-type="topic"]',
|
||||
'[data-type="subtopic"]',
|
||||
]);
|
||||
for (const clickableElement of matchingElements) {
|
||||
clickableElement.classList.remove('done', 'skipped', 'learning', 'removed');
|
||||
}
|
||||
}
|
||||
@ -284,6 +288,19 @@ export async function renderResourceProgress(
|
||||
refreshProgressCounters();
|
||||
}
|
||||
|
||||
function getMatchingElements(
|
||||
quries: string[],
|
||||
parentElement: Document | SVGElement = document
|
||||
): Element[] {
|
||||
const matchingElements: Element[] = [];
|
||||
quries.forEach((query) => {
|
||||
parentElement.querySelectorAll(query).forEach((element) => {
|
||||
matchingElements.push(element);
|
||||
});
|
||||
});
|
||||
return matchingElements;
|
||||
}
|
||||
|
||||
export function refreshProgressCounters() {
|
||||
const progressNumsContainers = document.querySelectorAll(
|
||||
'[data-progress-nums-container]'
|
||||
@ -293,7 +310,12 @@ export function refreshProgressCounters() {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalClickable = document.querySelectorAll('.clickable-group').length;
|
||||
const totalClickable = getMatchingElements([
|
||||
'.clickable-group',
|
||||
'[data-type="topic"]',
|
||||
'[data-type="subtopic"]',
|
||||
]).length;
|
||||
|
||||
const externalLinks = document.querySelectorAll(
|
||||
'[data-group-id^="ext_link:"]'
|
||||
).length;
|
||||
@ -325,14 +347,18 @@ export function refreshProgressCounters() {
|
||||
totalRemoved;
|
||||
|
||||
const totalDone =
|
||||
document.querySelectorAll('.clickable-group.done:not([data-group-id^="ext_link:"])').length -
|
||||
totalCheckBoxesDone;
|
||||
getMatchingElements([
|
||||
'.clickable-group.done:not([data-group-id^="ext_link:"])',
|
||||
'[data-node-id].done', // All data-node-id=*.done elements are custom roadmap nodes
|
||||
]).length - totalCheckBoxesDone;
|
||||
const totalLearning =
|
||||
document.querySelectorAll('.clickable-group.learning').length -
|
||||
totalCheckBoxesLearning;
|
||||
getMatchingElements([
|
||||
'.clickable-group.learning',
|
||||
'[data-node-id].learning',
|
||||
]).length - totalCheckBoxesLearning;
|
||||
const totalSkipped =
|
||||
document.querySelectorAll('.clickable-group.skipped').length -
|
||||
totalCheckBoxesSkipped;
|
||||
getMatchingElements(['.clickable-group.skipped', '[data-node-id].skipped'])
|
||||
.length - totalCheckBoxesSkipped;
|
||||
|
||||
const doneCountEls = document.querySelectorAll('[data-progress-done]');
|
||||
if (doneCountEls.length > 0) {
|
||||
@ -364,9 +390,8 @@ export function refreshProgressCounters() {
|
||||
);
|
||||
}
|
||||
|
||||
const progressPercentage = Math.round(
|
||||
((totalDone + totalSkipped) / totalItems) * 100
|
||||
);
|
||||
const progressPercentage =
|
||||
Math.round(((totalDone + totalSkipped) / totalItems) * 100) || 0;
|
||||
const progressPercentageEls = document.querySelectorAll(
|
||||
'[data-progress-percentage]'
|
||||
);
|
||||
|
@ -1,7 +1,10 @@
|
||||
---
|
||||
import RoadmapBanner from '../../components/RoadmapBanner.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getRoadmapTopicFiles,RoadmapTopicFileType } from '../../lib/roadmap-topic';
|
||||
import {
|
||||
getRoadmapTopicFiles,
|
||||
type RoadmapTopicFileType,
|
||||
} from '../../lib/roadmap-topic';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const topicPathMapping = await getRoadmapTopicFiles();
|
||||
@ -22,9 +25,11 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { topicId } = Astro.params;
|
||||
const { file, roadmapId, roadmap, heading } = Astro.props as RoadmapTopicFileType;
|
||||
const { file, roadmapId, roadmap, heading } =
|
||||
Astro.props as RoadmapTopicFileType;
|
||||
|
||||
const gitHubBaseUrl = 'https://github.com/kamranahmedse/developer-roadmap/blob/master/src/data';
|
||||
const gitHubBaseUrl =
|
||||
'https://github.com/kamranahmedse/developer-roadmap/blob/master/src/data';
|
||||
const gitHubFullUrl = file.file.replace(/^.+\/src\/data/, `${gitHubBaseUrl}/`);
|
||||
const gitHubRelativeUrl = file.file.replace(/^.+\/src\/data/, 'src/data');
|
||||
---
|
||||
@ -37,14 +42,21 @@ const gitHubRelativeUrl = file.file.replace(/^.+\/src\/data/, 'src/data');
|
||||
>
|
||||
<RoadmapBanner roadmapId={roadmapId} roadmap={roadmap} />
|
||||
<div class='bg-gray-50'>
|
||||
|
||||
<div class='container pb-16 prose prose-p:mt-0 prose-h1:mb-4 prose-h2:mb-3 prose-h2:mt-0'>
|
||||
<div
|
||||
class='container prose pb-16 prose-h1:mb-4 prose-h2:mb-3 prose-h2:mt-0 prose-p:mt-0'
|
||||
>
|
||||
<main id='main-content'>
|
||||
<file.Content />
|
||||
</main>
|
||||
|
||||
<p class="border border-yellow-500 p-2 rounded-md text-sm bg-white">
|
||||
Found any mistakes? Help us improve by <a id="gh-file-url" rel="nofollow" target="_blank" data-relative-url={gitHubRelativeUrl} href={gitHubFullUrl}>updating the file here.</a>.
|
||||
<p class='rounded-md border border-yellow-500 bg-white p-2 text-sm'>
|
||||
Found any mistakes? Help us improve by <a
|
||||
id='gh-file-url'
|
||||
rel='nofollow'
|
||||
target='_blank'
|
||||
data-relative-url={gitHubRelativeUrl}
|
||||
href={gitHubFullUrl}>updating the file here.</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
generateArticleSchema,
|
||||
generateFAQSchema,
|
||||
} from '../../lib/jsonld-schema';
|
||||
import { RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
|
||||
import { type RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const roadmapIds = await getRoadmapIds();
|
||||
@ -97,7 +97,7 @@ if (roadmapFAQs.length) {
|
||||
description={roadmapData.briefDescription}
|
||||
pageUrl={`https://roadmap.sh/${roadmapId}`}
|
||||
/>
|
||||
<TopicDetail client:idle />
|
||||
<TopicDetail client:idle canSubmitContribution={true} />
|
||||
|
||||
<FrameRenderer
|
||||
resourceType={'roadmap'}
|
||||
@ -122,7 +122,7 @@ if (roadmapFAQs.length) {
|
||||
<UserProgressModal
|
||||
resourceId={roadmapId}
|
||||
resourceType='roadmap'
|
||||
client:only="react"
|
||||
client:only='react'
|
||||
/>
|
||||
|
||||
<FAQs faqs={roadmapFAQs} />
|
||||
|
15
src/pages/account/roadmaps.astro
Normal file
15
src/pages/account/roadmaps.astro
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
import AccountSidebar from '../../components/AccountSidebar.astro';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
import { RoadmapListPage } from '../../components/CustomRoadmap/RoadmapListPage';
|
||||
---
|
||||
|
||||
<AccountLayout
|
||||
title='Roadmaps'
|
||||
noIndex={true}
|
||||
initialLoadingMessage='Loading roadmaps'
|
||||
>
|
||||
<AccountSidebar activePageId='roadmaps' activePageTitle='Roadmaps'>
|
||||
<RoadmapListPage client:only='react' />
|
||||
</AccountSidebar>
|
||||
</AccountLayout>
|
@ -9,9 +9,8 @@ import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import { UserProgressModal } from '../../../components/UserProgress/UserProgressModal';
|
||||
import {
|
||||
type BestPracticeFileType,
|
||||
BestPracticeFrontmatter,
|
||||
type BestPracticeFrontmatter,
|
||||
getAllBestPractices,
|
||||
getBestPracticeIds,
|
||||
} from '../../../lib/best-pratice';
|
||||
import { generateArticleSchema } from '../../../lib/jsonld-schema';
|
||||
|
||||
@ -90,7 +89,7 @@ if (bestPracticeData.schema) {
|
||||
pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`}
|
||||
/>
|
||||
|
||||
<TopicDetail client:idle />
|
||||
<TopicDetail client:idle canSubmitContribution={true} />
|
||||
|
||||
<FrameRenderer
|
||||
resourceType={'best-practice'}
|
||||
|
@ -35,6 +35,7 @@ const videos = await getAllVideos();
|
||||
isNew: roadmapItem.frontmatter.isNew,
|
||||
isUpcoming: roadmapItem.frontmatter.isUpcoming,
|
||||
}))}
|
||||
showCreateRoadmap={true}
|
||||
/>
|
||||
|
||||
<FeaturedItems
|
||||
@ -48,6 +49,7 @@ const videos = await getAllVideos();
|
||||
isNew: roadmapItem.frontmatter.isNew,
|
||||
isUpcoming: roadmapItem.frontmatter.isUpcoming,
|
||||
}))}
|
||||
showCreateRoadmap={true}
|
||||
/>
|
||||
|
||||
<FeaturedItems
|
||||
|
22
src/pages/r/index.astro
Normal file
22
src/pages/r/index.astro
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { CustomRoadmap } from '../../components/CustomRoadmap/CustomRoadmap';
|
||||
import { SkeletonRoadmapHeader } from '../../components/CustomRoadmap/SkeletonRoadmapHeader';
|
||||
import Loader from '../../components/Loader.astro';
|
||||
import ProgressHelpPopup from '../../components/ProgressHelpPopup.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title='Roadmaps'>
|
||||
<ProgressHelpPopup />
|
||||
<div>
|
||||
<div class='flex min-h-[550px] flex-col'>
|
||||
<div data-roadmap-loader class='flex w-full grow flex-col'>
|
||||
<SkeletonRoadmapHeader />
|
||||
<div class='flex grow items-center justify-center'>
|
||||
<Loader />
|
||||
</div>
|
||||
</div>
|
||||
<CustomRoadmap client:only='react' />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
import { TeamSidebar } from '../../components/TeamSidebar';
|
||||
import { TeamRoadmaps } from '../../components/TeamRoadmaps';
|
||||
import { TeamRoadmaps } from '../../components/TeamRoadmapsList/TeamRoadmaps';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
|
12
src/stores/roadmap.ts
Normal file
12
src/stores/roadmap.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { atom, computed } from 'nanostores';
|
||||
import { type RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
|
||||
export const currentRoadmap = atom<RoadmapDocument | undefined>(undefined);
|
||||
export const isCurrentRoadmapPersonal = computed(
|
||||
currentRoadmap,
|
||||
(roadmap) => !roadmap?.teamId
|
||||
);
|
||||
export const canManageCurrentRoadmap = computed(
|
||||
currentRoadmap,
|
||||
(roadmap) => roadmap?.canManage
|
||||
);
|
@ -25,9 +25,19 @@ module.exports = {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
'fade-in': {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-slide-up': 'fade-slide-up 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
||||
'fade-slide-up':
|
||||
'fade-slide-up 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
||||
'fade-in': 'fade-in 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
||||
},
|
||||
},
|
||||
container: {
|
||||
|
Loading…
x
Reference in New Issue
Block a user