mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-13 10:11:55 +08:00
Compare commits
41 Commits
chore/java
...
feat/membe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b96d84ec | ||
|
|
3a01e5c349 | ||
|
|
29cff6a6f8 | ||
|
|
044df81b7a | ||
|
|
3151ee5021 | ||
|
|
e6ce9f40ee | ||
|
|
3b5e3c44f9 | ||
|
|
c286e0a6f8 | ||
|
|
3bebe0c1de | ||
|
|
9845fe624a | ||
|
|
4b2b2ebe8c | ||
|
|
82c2aaacc3 | ||
|
|
6d1edb76c7 | ||
|
|
5d57d5baaf | ||
|
|
d31d626c61 | ||
|
|
71bf34e683 | ||
|
|
93a91b1d9b | ||
|
|
18c8bd14b2 | ||
|
|
e34695e334 | ||
|
|
8310671123 | ||
|
|
d45c8f9cb2 | ||
|
|
573263ed74 | ||
|
|
f27aa58ac3 | ||
|
|
518cf4ce73 | ||
|
|
7bde0b3f44 | ||
|
|
4b6dcb3a37 | ||
|
|
c50200bfe7 | ||
|
|
5ffb9fad9f | ||
|
|
dd7d312aa1 | ||
|
|
81447f6b43 | ||
|
|
fe711f498d | ||
|
|
c65f12fcb8 | ||
|
|
cab075bf5b | ||
|
|
685021493c | ||
|
|
482cf64bf5 | ||
|
|
9051e22476 | ||
|
|
1b538b399f | ||
|
|
05673087c5 | ||
|
|
5256df9c07 | ||
|
|
ddf8884501 | ||
|
|
05492b60ee |
@@ -1,2 +1,3 @@
|
||||
PUBLIC_API_URL=http://api.roadmap.sh
|
||||
PUBLIC_API_URL=https://api.roadmap.sh
|
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
||||
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
|
||||
@@ -14,24 +14,12 @@ body:
|
||||
placeholder: e.g. Roadmap to learn Data Science
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: Is this roadmap prepared by you or someone else?
|
||||
options:
|
||||
- I prepared this roadmap
|
||||
- I found this roadmap online (please provide a link below)
|
||||
- type: textarea
|
||||
id: roadmap-description
|
||||
attributes:
|
||||
label: Roadmap Items
|
||||
description: Please submit a nested list of items which we can convert into the visual. Here is an [example of roadmap items list.](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5).
|
||||
label: Roadmap Link
|
||||
description: Please create the roadmap [using our roadmap editor](https://twitter.com/kamrify/status/1708293162693767426) and submit the roadmap link.
|
||||
placeholder: |
|
||||
- Item 1
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
- Item 2
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
https://roadmap.sh/xyz
|
||||
validations:
|
||||
required: true
|
||||
required: true
|
||||
|
||||
7
.github/workflows/deploy.yml
vendored
7
.github/workflows/deploy.yml
vendored
@@ -4,9 +4,9 @@ on:
|
||||
branches: [ master ]
|
||||
env:
|
||||
PUBLIC_API_URL: "https://api.roadmap.sh"
|
||||
PUBLIC_EDITOR_APP_URL: "https://draw.roadmap.sh"
|
||||
PUBLIC_AVATAR_BASE_URL: "https://dodrc8eu8m09s.cloudfront.net/avatars"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PAT: ${{ secrets.PAT }}
|
||||
CI: true
|
||||
jobs:
|
||||
build:
|
||||
@@ -18,7 +18,9 @@ jobs:
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 18
|
||||
- run: git config --global url."https://${{ secrets.PAT }}@github.com/".insteadOf ssh://git@github.com/
|
||||
- name: Prepare Draw Repository
|
||||
run: |
|
||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7.13.4
|
||||
@@ -27,6 +29,7 @@ jobs:
|
||||
pnpm install
|
||||
- name: Generate meta and build
|
||||
run: |
|
||||
npm run generate-renderer
|
||||
npm run build
|
||||
touch ./dist/.nojekyll
|
||||
echo 'roadmap.sh' > ./dist/CNAME
|
||||
|
||||
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
|
||||
11028
package-lock.json
generated
Normal file
11028
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<a href="https://roadmap.sh/best-practices">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Best%20Practices-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="best practices" />
|
||||
</a>
|
||||
<a href="https://youtube.com/theroadmap?sub_confirmation=1">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
<a href="https://roadmap.sh/questions">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Questions-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
</a>
|
||||
<a href="https://www.youtube.com/channel/UCA0H2KIWgWTwpTFjSxp0now?sub_confirmation=1">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
|
||||
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");
|
||||
}
|
||||
33
scripts/generate-renderer.sh
Normal file
33
scripts/generate-renderer.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# ignore cloning if .temp/web-draw already exists
|
||||
if [ ! -d ".temp/web-draw" ]; then
|
||||
mkdir -p .temp
|
||||
git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw
|
||||
fi
|
||||
|
||||
rm -rf renderer
|
||||
mkdir renderer
|
||||
|
||||
# copy the files at /src/editor/renderer/* to /renderer
|
||||
# while replacing any existing files
|
||||
cp -rf .temp/web-draw/src/editor/renderer/* renderer
|
||||
|
||||
# Add @ts-nocheck to the top of each ts and tsx file
|
||||
# so that the typescript compiler doesn't complain
|
||||
# about the missing types
|
||||
find renderer -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= read -r -d '' file; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "// @ts-nocheck" > temp
|
||||
cat "$file" >> temp
|
||||
mv temp "$file"
|
||||
echo "Added @ts-nocheck to $file"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
|
||||
# ignore the worktree changes for the renderer directory
|
||||
git update-index --skip-worktree renderer/*
|
||||
@@ -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 your own 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>
|
||||
);
|
||||
}
|
||||
133
src/components/CustomRoadmap/CustomRoadmap.tsx
Normal file
133
src/components/CustomRoadmap/CustomRoadmap.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import {
|
||||
type AppError,
|
||||
type FetchError,
|
||||
httpGet,
|
||||
httpPost,
|
||||
} from '../../lib/http';
|
||||
import { RoadmapHeader } from './RoadmapHeader';
|
||||
import { RoadmapRenderer } from './RoadmapRenderer';
|
||||
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { currentRoadmap } from '../../stores/roadmap';
|
||||
import { UserProgressModal } from '../UserProgress/UserProgressModal';
|
||||
import { RestrictedPage } from './RestrictedPage';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
export const allowedLinkTypes = [
|
||||
'video',
|
||||
'article',
|
||||
'opensource',
|
||||
'course',
|
||||
'website',
|
||||
'podcast',
|
||||
] as const;
|
||||
|
||||
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
|
||||
|
||||
export interface RoadmapContentDocument {
|
||||
_id?: string;
|
||||
roadmapId: string;
|
||||
nodeId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
links: {
|
||||
id: string;
|
||||
type: AllowedLinkTypes;
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type CreatorType = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
export type GetRoadmapResponse = RoadmapDocument & {
|
||||
canManage: boolean;
|
||||
creator?: CreatorType;
|
||||
team?: CreatorType;
|
||||
};
|
||||
|
||||
export function hideRoadmapLoader() {
|
||||
const loaderEl = document.querySelector(
|
||||
'[data-roadmap-loader]'
|
||||
) as HTMLElement;
|
||||
if (loaderEl) {
|
||||
loaderEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomRoadmap() {
|
||||
const { id, secret } = getUrlParams() as { id: string; secret: string };
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [roadmap, setRoadmap] = useState<GetRoadmapResponse | null>(null);
|
||||
const [error, setError] = useState<AppError | FetchError | undefined>();
|
||||
|
||||
async function getRoadmap() {
|
||||
setIsLoading(true);
|
||||
|
||||
const roadmapUrl = new URL(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`
|
||||
);
|
||||
if (secret) {
|
||||
roadmapUrl.searchParams.set('secret', secret);
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<GetRoadmapResponse>(
|
||||
roadmapUrl.toString()
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = `${response.title} - roadmap.sh`;
|
||||
|
||||
setRoadmap(response);
|
||||
currentRoadmap.set(response);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function trackVisit() {
|
||||
if (!isLoggedIn()) return;
|
||||
await httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
|
||||
resourceId: id,
|
||||
resourceType: 'roadmap',
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getRoadmap().finally(() => {
|
||||
hideRoadmapLoader();
|
||||
});
|
||||
trackVisit().then();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <RestrictedPage error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoadmapHeader />
|
||||
<RoadmapRenderer roadmap={roadmap!} />
|
||||
<TopicDetail canSubmitContribution={false} />
|
||||
<UserProgressModal
|
||||
resourceId={roadmap?._id!}
|
||||
resourceType="roadmap"
|
||||
isCustomResource={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
175
src/components/CustomRoadmap/RoadmapHeader.tsx
Normal file
175
src/components/CustomRoadmap/RoadmapHeader.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
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,
|
||||
creator,
|
||||
team,
|
||||
} = 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
const avatarUrl = creator?.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${creator?.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container relative py-5 sm:py-12">
|
||||
{creator?.name && (
|
||||
<div className="-mb-1 flex items-center gap-1.5 text-sm text-gray-500">
|
||||
<img
|
||||
alt={creator.name}
|
||||
src={avatarUrl}
|
||||
className="h-5 w-5 rounded-full"
|
||||
/>
|
||||
<span>
|
||||
Created by
|
||||
<span className="font-semibold text-gray-900">
|
||||
{creator?.name}
|
||||
</span>
|
||||
{team && (
|
||||
<>
|
||||
in
|
||||
<span className="font-semibold text-gray-900">
|
||||
{team?.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 mt-4 sm:mb-4">
|
||||
<h1 className="text-2xl font-bold sm:mb-2 sm:text-4xl">{title}</h1>
|
||||
<p className="mt-0.5 text-sm text-gray-500 sm:text-lg">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 sm:gap-0">
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
aria-label="Back to All Roadmaps"
|
||||
>
|
||||
←<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 flex-col justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
|
||||
<div className="flex grow items-center gap-2">
|
||||
{tabTypes.map((tab) => {
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
className={`relative flex w-full items-center justify-center whitespace-nowrap rounded-md border p-1 px-3 text-sm sm:w-auto ${
|
||||
activeTab === tab.value ? ' border-gray-400 bg-gray-200 ' : ''
|
||||
} w-full sm:w-auto`}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className={`relative flex w-full items-center justify-center rounded-md border p-1 px-3 text-sm sm:w-auto`}
|
||||
onClick={() => setIsCreatingRoadmap(true)}
|
||||
>
|
||||
+ Create Roadmap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{activeTab === 'personal' && (
|
||||
<PersonalRoadmapList
|
||||
roadmaps={allRoadmaps?.personalRoadmaps}
|
||||
setAllRoadmaps={setAllRoadmaps}
|
||||
onDelete={(roadmapId) => {
|
||||
setAllRoadmaps({
|
||||
...allRoadmaps,
|
||||
personalRoadmaps: allRoadmaps.personalRoadmaps.filter(
|
||||
(r) => r._id !== roadmapId
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'shared' && (
|
||||
<SharedRoadmapList roadmaps={allRoadmaps?.sharedRoadmaps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
32
src/components/CustomRoadmap/SkeletonRoadmapHeader.tsx
Normal file
32
src/components/CustomRoadmap/SkeletonRoadmapHeader.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
export function SkeletonRoadmapHeader() {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container relative py-5 sm:py-12">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-4 w-4 animate-pulse rounded-full bg-gray-300" />
|
||||
<div className="h-5 w-5/12 animate-pulse rounded-md bg-gray-200" />
|
||||
</div>
|
||||
<div className="mb-3 mt-4 sm:mb-4">
|
||||
<div className="h-8 w-1/2 animate-pulse rounded-md bg-gray-300 sm:mb-2 sm:h-10" />
|
||||
<div className="mt-0.5 h-5 w-1/3 animate-pulse rounded-md bg-gray-200 sm:h-7" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 sm:gap-0">
|
||||
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-32" />
|
||||
<div className="h-7 w-[32px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[89.73px]" />
|
||||
</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
|
||||
|
||||
66
src/components/ShareOptions/CopyRoadmapLink.tsx
Normal file
66
src/components/ShareOptions/CopyRoadmapLink.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
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 baseUrl = import.meta.env.DEV
|
||||
? 'http://localhost:3000'
|
||||
: 'https://roadmap.sh';
|
||||
const shareLink = `${baseUrl}/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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
348
src/components/ShareOptions/ShareOptionsModal.tsx
Normal file
348
src/components/ShareOptions/ShareOptionsModal.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { type ReactNode, useCallback, useState, useMemo } 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[]>([]);
|
||||
|
||||
// Using global team members loading state to avoid glitchy UI when switching between teams
|
||||
const [isTeamMembersLoading, setIsTeamMembersLoading] = useState(false);
|
||||
const membersCache = useMemo(() => new Map<string, TeamMemberList[]>(), []);
|
||||
|
||||
const [visibility, setVisibility] = useState(defaultVisibility);
|
||||
const [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, sharedTeamMemberIds: string[]) => {
|
||||
if (!roadmapId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-transfer-roadmap/${roadmapId}`,
|
||||
{
|
||||
teamId,
|
||||
sharedTeamMemberIds,
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
setIsTeamMembersLoading(true);
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* For Team Roadmap */}
|
||||
{visibility === 'team' && teamId && (
|
||||
<ShareTeamMemberList
|
||||
teamId={teamId}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
membersCache={membersCache}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canTransferRoadmap && (
|
||||
<>
|
||||
<TransferToTeamList
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
selectedTeamId={selectedTeamId}
|
||||
setSelectedTeamId={setSelectedTeamId}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
onTeamChange={() => {
|
||||
setIsTeamMembersLoading(true);
|
||||
setSharedTeamMemberIds([]);
|
||||
}}
|
||||
/>
|
||||
{selectedTeamId && (
|
||||
<>
|
||||
<hr className="-mx-4 my-4" />
|
||||
<div className="mb-4">
|
||||
<ShareTeamMemberList
|
||||
title="Select who can access this roadmap. You can change this later."
|
||||
teamId={selectedTeamId!}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
membersCache={membersCache}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-1.5">
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 rounded-md border px-3.5 py-1.5 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-75"
|
||||
disabled={isLoading}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
{canTransferRoadmap && (
|
||||
<UpdateAction
|
||||
disabled={
|
||||
isUpdateDisabled || isLoading || sharedTeamMemberIds.length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
handleTransferToTeam(selectedTeamId!, sharedTeamMemberIds).then(
|
||||
() => null
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Transfer
|
||||
</UpdateAction>
|
||||
)}
|
||||
|
||||
{!canTransferRoadmap && (
|
||||
<UpdateAction
|
||||
disabled={isUpdateDisabled || isLoading}
|
||||
onClick={() => {
|
||||
handleShareChange({
|
||||
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>
|
||||
);
|
||||
}
|
||||
170
src/components/ShareOptions/ShareTeamMemberList.tsx
Normal file
170
src/components/ShareOptions/ShareTeamMemberList.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { UserItem } from './UserItem';
|
||||
import { Users } from 'lucide-react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
const allowedRoles = ['admin', 'manager', 'member'] as const;
|
||||
const allowedStatus = ['invited', 'joined', 'rejected'] as const;
|
||||
|
||||
export type AllowedMemberRoles = (typeof allowedRoles)[number];
|
||||
export type AllowedMemberStatus = (typeof allowedStatus)[number];
|
||||
|
||||
export interface TeamMemberDocument {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
invitedEmail?: string;
|
||||
teamId: string;
|
||||
role: AllowedMemberRoles;
|
||||
status: AllowedMemberStatus;
|
||||
progressReminderCount: number;
|
||||
lastProgressReminderAt?: Date;
|
||||
lastResendInviteAt?: Date;
|
||||
resendInviteCount?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TeamMemberList extends TeamMemberDocument {
|
||||
name: string;
|
||||
avatar: string;
|
||||
hasProgress: boolean;
|
||||
}
|
||||
|
||||
type ShareTeamMemberListProps = {
|
||||
teamId: string;
|
||||
title?: string;
|
||||
sharedTeamMemberIds: string[];
|
||||
setSharedTeamMemberIds: (sharedTeamMemberIds: string[]) => void;
|
||||
|
||||
membersCache: Map<string, TeamMemberList[]>;
|
||||
isTeamMembersLoading: boolean;
|
||||
setIsTeamMembersLoading: (isLoading: boolean) => void;
|
||||
};
|
||||
|
||||
export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
|
||||
const {
|
||||
teamId,
|
||||
title = 'Select Members',
|
||||
sharedTeamMemberIds,
|
||||
setSharedTeamMemberIds,
|
||||
|
||||
membersCache,
|
||||
isTeamMembersLoading: isLoading,
|
||||
setIsTeamMembersLoading: setIsLoading,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
async function loadTeamMembers() {
|
||||
if (membersCache.has(teamId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpGet<TeamMemberList[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-list/${teamId}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
membersCache.set(teamId, response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTeamMembers().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
const loadingMembers = isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-2.5">
|
||||
{[...Array(3)].map((_, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex min-h-[66px] animate-pulse items-center gap-2 rounded-md border p-2"
|
||||
>
|
||||
<div className="h-8 w-8 shrink-0 rounded-full bg-gray-200" />
|
||||
<div className="inline-grid w-full">
|
||||
<div className="h-5 w-2/4 rounded bg-gray-200" />
|
||||
<div className="mt-1 h-5 w-3/4 rounded bg-gray-200" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
const members = membersCache.get(teamId) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{(members.length > 0 || isLoading) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm">{title}</p>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sharedTeamMemberIds.length === members.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSharedTeamMemberIds(members.map((member) => member._id!));
|
||||
} else {
|
||||
setSharedTeamMemberIds([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">Select all</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingMembers}
|
||||
{members?.length > 0 && !isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-2.5">
|
||||
{members?.map((member) => {
|
||||
const isSelected = sharedTeamMemberIds?.includes(
|
||||
member._id?.toString()!
|
||||
);
|
||||
return (
|
||||
<li key={member.userId}>
|
||||
<UserItem
|
||||
user={{
|
||||
name: member.name,
|
||||
avatar: member.avatar,
|
||||
email: member.invitedEmail!,
|
||||
}}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSharedTeamMemberIds(
|
||||
sharedTeamMemberIds.filter(
|
||||
(id) => id !== member._id?.toString()!
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setSharedTeamMemberIds([
|
||||
...sharedTeamMemberIds,
|
||||
member._id?.toString()!,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{members.length === 0 && !isLoading && (
|
||||
<div className="flex grow flex-col items-center justify-center gap-2">
|
||||
<Users className="h-12 w-12 text-gray-500" />
|
||||
<p className="text-gray-500">No members have been added yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
src/components/ShareOptions/TransferToTeamList.tsx
Normal file
132
src/components/ShareOptions/TransferToTeamList.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { Users2 } from 'lucide-react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { UserTeamItem } from '../TeamDropdown/TeamDropdown';
|
||||
|
||||
type TransferToTeamListProps = {
|
||||
teams: UserTeamItem[];
|
||||
setTeams: (teams: UserTeamItem[]) => void;
|
||||
|
||||
selectedTeamId: string | null;
|
||||
setSelectedTeamId: (teamId: string | null) => void;
|
||||
|
||||
isTeamMembersLoading: boolean;
|
||||
setIsTeamMembersLoading: (isLoading: boolean) => void;
|
||||
onTeamChange: (teamId: string | null) => void;
|
||||
};
|
||||
|
||||
export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
const {
|
||||
teams,
|
||||
setTeams,
|
||||
selectedTeamId,
|
||||
setSelectedTeamId,
|
||||
isTeamMembersLoading,
|
||||
setIsTeamMembersLoading,
|
||||
onTeamChange,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function getAllTeams() {
|
||||
if (teams.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<UserTeamItem[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeams(
|
||||
response.filter((team) => ['admin', 'manager'].includes(team.role))
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getAllTeams().finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadingTeams = isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<li key={index}>
|
||||
<div className="relative flex w-full items-center gap-2 rounded-md border p-2">
|
||||
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-gray-200" />
|
||||
<div className="inline-grid w-full">
|
||||
<div className="h-4 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(teams.length > 0 || isLoading) && (
|
||||
<p className="text-sm">Select a team to transfer this roadmap to</p>
|
||||
)}
|
||||
|
||||
{loadingTeams}
|
||||
{teams.length > 0 && !isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{teams.map((team) => {
|
||||
const isSelected = team._id === selectedTeamId;
|
||||
|
||||
return (
|
||||
<li key={team._id}>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5 disabled:cursor-not-allowed disabled:opacity-70',
|
||||
isSelected && 'border-gray-500 bg-gray-100 text-black'
|
||||
)}
|
||||
disabled={isTeamMembersLoading}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSelectedTeamId(null);
|
||||
} else {
|
||||
setSelectedTeamId(team._id);
|
||||
}
|
||||
onTeamChange(team._id);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
team.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
team.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={team.name || ''}
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
/>
|
||||
<div className="inline-grid w-full">
|
||||
<h3 className="truncate text-left font-normal">
|
||||
{team.name}
|
||||
</h3>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{teams.length === 0 && !isLoading && (
|
||||
<div className="flex grow flex-col items-center justify-center gap-2">
|
||||
<Users2 className="h-12 w-12 text-gray-500" />
|
||||
<p className="text-gray-500">You are not a member of any team.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ export interface TeamMemberItem extends TeamMemberDocument {
|
||||
hasProgress: boolean;
|
||||
}
|
||||
|
||||
const MAX_MEMBER_COUNT = 100;
|
||||
|
||||
export function TeamMembersPage() {
|
||||
const { t: teamId } = getUrlParams();
|
||||
|
||||
@@ -307,7 +309,7 @@ export function TeamMembersPage() {
|
||||
{canManageCurrentTeam && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
disabled={teamMembers.length >= 25}
|
||||
disabled={teamMembers.length >= MAX_MEMBER_COUNT}
|
||||
onClick={() => setIsInvitingMember(true)}
|
||||
className="block w-full rounded-md border border-dashed border-gray-300 py-2 text-sm transition-colors hover:border-gray-600 hover:bg-gray-50 focus:outline-0"
|
||||
>
|
||||
@@ -316,7 +318,7 @@ export function TeamMembersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teamMembers.length >= 25 && canManageCurrentTeam && (
|
||||
{teamMembers.length >= MAX_MEMBER_COUNT && canManageCurrentTeam && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
|
||||
You have reached the maximum number of members in a team. Please reach
|
||||
out to us if you need more.
|
||||
|
||||
@@ -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,8 +40,13 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
resourceType,
|
||||
userId: propUserId,
|
||||
onClose: onModalClose,
|
||||
isCustomResource,
|
||||
} = props;
|
||||
|
||||
const { s: userId = propUserId } = getUrlParams();
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resourceSvgEl = useRef<HTMLDivElement>(null);
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
@@ -62,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,
|
||||
@@ -88,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',
|
||||
});
|
||||
@@ -97,7 +116,12 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
deleteUrlParam('s');
|
||||
setError('');
|
||||
setShowModal(false);
|
||||
onModalClose?.();
|
||||
|
||||
if (onModalClose) {
|
||||
onModalClose();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
@@ -156,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);
|
||||
})
|
||||
@@ -177,7 +209,7 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
const userLearning = progress?.learning?.length || 0;
|
||||
const userSkipped = progress?.skipped?.length || 0;
|
||||
|
||||
if (!userId || currentUser?.id === userId) {
|
||||
if (currentUser?.id === userId) {
|
||||
deleteUrlParam('s');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
> Choose your image format appropriately
|
||||
|
||||
To ensure that your images don't slow your website, choose the format that will correspond to your image. If it's a photo, JPEG is most of the time more appropriate than PNG or GIF. But don't forget to look a the nex-gen formats which can reduce the size of your files. Each image format has pros and cons, it's important to know these to make the best choice possible.
|
||||
To ensure that your images don't slow your website, choose the format that will correspond to your image. If it's a photo, JPEG is most of the time more appropriate than PNG or GIF. However, don't forget to look at the more modern formats that can reduce the size of your files. Each image format has pros and cons, so it's important to know these to make the best choice possible.
|
||||
|
||||
Use [Lighthouse](https://developers.google.com/web/tools/lighthouse/) to identify which images can eventually use next-gen formats (like JPEG 2000m JPEG XR or WebP). Compare different formats, sometimes using PNG8 is better than PNG16, sometimes it's not.
|
||||
Use [Lighthouse](https://developers.google.com/web/tools/lighthouse/) to identify which images can eventually use modern formats (like JPEG 2000m, JPEG XR or WebP). Compare different formats, sometimes using PNG8 is better than PNG16, sometimes it's not.
|
||||
|
||||
- [Serve Images in Next-Gen Formats](https://developers.google.com/web/tools/lighthouse/audits/webp)
|
||||
- [Serve Images in Modern Formats](https://developers.google.com/web/tools/lighthouse/audits/webp)
|
||||
- [What Is the Right Image Format for Your Website?](https://www.sitepoint.com/what-is-the-right-image-format-for-your-website/)
|
||||
- [PNG8 - The Clear Winner](https://www.sitepoint.com/png8-the-clear-winner/)
|
||||
- [8-bit vs 16-bit - What Color Depth You Should Use And Why It Matters](https://www.diyphotography.net/8-bit-vs-16-bit-color-depth-use-matters/)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
# Classic/Advanced ML
|
||||
|
||||
- [Open Machine Learning Course](https://mlcourse.ai/book/topic01/topic01_intro.html)
|
||||
- [Coursera: Machine Learning Spcialization](https://www.coursera.org/specializations/machine-learning-introduction#courses)
|
||||
- [Coursera: Machine Learning Specialization](https://imp.i384100.net/oqGkrg)
|
||||
- [Pattern Recognition and Machine Learning by Christopher Bishop](https://www.microsoft.com/en-us/research/uploads/prod/2006/01/Bishop-Pattern-Recognition-and-Machine-Learning-2006.pdf)
|
||||
- [Repository of notes, code and notebooks in Python for the book Pattern Recognition and Machine Learning by Christopher Bishop](https://github.com/gerdm/prml)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Data Understanding, Analysis and Visualization
|
||||
|
||||
- [Exploratory Data Analysis With Python and Pandas](https://www.coursera.org/projects/exploratory-data-analysis-python-pandas)
|
||||
- [Exploratory Data Analysis for Machine Learning](https://www.coursera.org/learn/ibm-exploratory-data-analysis-for-machine-learning#syllabus)
|
||||
- [Exploratory Data Analysis with Seaborn](https://www.coursera.org/projects/exploratory-data-analysis-seaborn)
|
||||
- [Exploratory Data Analysis With Python and Pandas](https://imp.i384100.net/AWAv4R)
|
||||
- [Exploratory Data Analysis for Machine Learning](https://imp.i384100.net/GmQMLE)
|
||||
- [Exploratory Data Analysis with Seaborn](https://imp.i384100.net/ZQmMgR)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# MLOps
|
||||
|
||||
- [Machine Learning Engineering for Production (MLOps) Specialization](https://www.coursera.org/specializations/machine-learning-engineering-for-production-mlops#courses)
|
||||
- [Machine Learning Engineering for Production (MLOps) Specialization](https://imp.i384100.net/nLA5mx)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Differential Calculus
|
||||
|
||||
- [Algebra and Differential Calculus for Data Science](https://coursera.org/learn/algebra-and-differential-calculus-for-data-science#syllabus)
|
||||
- [Algebra and Differential Calculus for Data Science](https://imp.i384100.net/LX5M7M)
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
- [The Illustrated Transformer](https://jalammar.github.io/illustrated-transformer/)
|
||||
- [Attention is All you Need](https://arxiv.org/pdf/1706.03762.pdf)
|
||||
- [Deep Learning Book](https://www.deeplearningbook.org/)
|
||||
- [Deep Learning Specialization](https://www.coursera.org/specializations/deep-learning#courses)
|
||||
- [Deep Learning Specialization](https://imp.i384100.net/Wq9MV3)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Hypothesis Testing
|
||||
|
||||
- [Introduction to Statistical Analysis: Hypothesis Testing](https://www.coursera.org/learn/statistical-analysis-hypothesis-testing-sas#syllabus)
|
||||
- [Introduction to Statistical Analysis: Hypothesis Testing](https://imp.i384100.net/vN0JAA)
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
- [Learn Algorithms](https://leetcode.com/explore/learn/)
|
||||
- [Leetcode - Study Plans](https://leetcode.com/studyplan/)
|
||||
- [Algorithms Specialization](https://coursera.org/specializations/algorithms#courses)
|
||||
- [Algorithms Specialization](https://imp.i384100.net/5gqv4n)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Learn Algebra, Calculus, Mathematical Analysis
|
||||
|
||||
- [Mathematics for Machine Learning Specialization](https://www.coursera.org/specializations/mathematics-machine-learning#courses)
|
||||
- [Mathematics for Machine Learning Specialization](https://imp.i384100.net/baqMYv)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Probability and Sampling
|
||||
|
||||
- [Probability and Statistics: To p or not to p?](https://www.coursera.org/learn/probability-statistics#syllabus)
|
||||
- [Probability and Statistics: To p or not to p?](https://imp.i384100.net/daDM6Q)
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
- [10 Fundamental Theorems for Econometrics](https://bookdown.org/ts_robinson1994/10EconometricTheorems/)
|
||||
- [Dougherty Intro to Econometrics 4th edition](https://www.academia.edu/33062577/Dougherty_Intro_to_Econometrics_4th_ed_small)
|
||||
- [Econometrics: Methods and Applications](https://www.coursera.org/learn/erasmus-econometrics#syllabus)
|
||||
- [Econometrics: Methods and Applications](https://imp.i384100.net/k0krYL)
|
||||
- [Kaggle - Learn Time Series](https://www.kaggle.com/learn/time-series)
|
||||
- [Time series Basics : Exploring traditional TS](https://www.kaggle.com/code/jagangupta/time-series-basics-exploring-traditional-ts#Hierarchical-time-series)
|
||||
- [How to Create an ARIMA Model for Time Series Forecasting in Python](https://machinelearningmastery.com/arima-for-time-series-forecasting-with-python)
|
||||
- [11 Classical Time Series Forecasting Methods in Python](https://machinelearningmastery.com/time-series-forecasting-methods-in-python-cheat-sheet/)
|
||||
- [Blockchain.com Data Scientist TakeHome Test](https://github.com/stalkermustang/bcdc_ds_takehome)
|
||||
- [Linear Regression for Business Statistics](https://www.coursera.org/learn/linear-regression-business-statistics#about)
|
||||
- [Linear Regression for Business Statistics](https://imp.i384100.net/9g97Ke)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Statistics, CLT
|
||||
|
||||
- [Introduction to Statistics](https://coursera.org/learn/stanford-statistics#syllabus)
|
||||
- [Introduction to Statistics](https://imp.i384100.net/3eRv4v)
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ Visit the following resources to learn more:
|
||||
- [throttleTime](https://rxjs.dev/api/operators/throttleTime)
|
||||
- [sampleTime](https://rxjs.dev/api/operators/sampleTime)
|
||||
- [auditTime](https://rxjs.dev/api/operators/auditTime)
|
||||
- [Blogs and tutorials on RxJS](https://blog.angular-university.io/rxjs-better-performance-with-the-rxjs-audittime-operator/)
|
||||
- [Blogs and tutorials on RxJS](https://blog.angular-university.io/functional-reactive-programming-for-angular-2-developers-rxjs-and-observables/)
|
||||
|
||||
@@ -7,6 +7,7 @@ Visit the following resources to learn more:
|
||||
- [Visit Dedicated Go Roadmap](/golang)
|
||||
- [A Tour of Go – Go Basics](https://go.dev/tour/welcome/1)
|
||||
- [Go Reference Documentation](https://go.dev/doc/)
|
||||
- [Learn Go | Boot.dev](https://boot.dev/learn/learn-golang)
|
||||
- [Go by Example - annotated example programs](https://gobyexample.com/)
|
||||
- [Learn Go | Codecademy](https://www.codecademy.com/learn/learn-go)
|
||||
- [W3Schools Go Tutorial ](https://www.w3schools.com/go/)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Cors
|
||||
|
||||
Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources.
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
|
||||
- [CORS in 100 Seconds](https://www.youtube.com/watch?v=4KHiSt0oLJ0)
|
||||
- [CORS in 6 minutes](https://www.youtube.com/watch?v=PNtFSVU-YTI)
|
||||
- [Understanding CORS](https://rbika.com/blog/understanding-cors)
|
||||
|
||||
@@ -12,4 +12,4 @@ Throttling is an important aspect of cloud design, as it helps to ensure that re
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [Throttling - AWS Well-Architected Framework](https://aws.amazon.com/architecture/well-architected/serverless/patterns/throttling/)
|
||||
- [Throttling - AWS Well-Architected Framework](https://docs.aws.amazon.com/wellarchitected/2022-03-31/framework/rel_mitigate_interaction_failure_throttle_requests.html)
|
||||
|
||||
@@ -7,5 +7,6 @@ Visit the following resources to learn more:
|
||||
- [What is Solana, and how does it work?](https://cointelegraph.com/news/what-is-solana-and-how-does-it-work)
|
||||
- [Beginners Guide To Solana](https://solana.com/news/getting-started-with-solana-development)
|
||||
- [Solana Introduction](https://docs.solana.com/introduction)
|
||||
- [Solana Whitepaper](https://solana.com/solana-whitepaper.pdf)
|
||||
- [Solana Architecture](https://docs.solana.com/cluster/overview)
|
||||
- [Start Building Solana!](https://beta.solpg.io/?utm_source=solana.com)
|
||||
|
||||
@@ -7,3 +7,4 @@ Many blockchains have forked the Ethereum blockchain and added functionality on
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [What is Ethereum Virtual Machine?](https://moralis.io/evm-explained-what-is-ethereum-virtual-machine/)
|
||||
- [Understanding the Ethereum Virtual Machine (EVM): Concepts and Architecture](https://www.youtube.com/watch?v=kCswGz9naZg)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# Polygon
|
||||
|
||||
Polygon, formerly known as the Matic Network, is a scaling solution that aims to provide multiple tools to improve the speed and reduce the cost and complexities of transactions on the Ethereum blockchain.
|
||||
Polygon, formerly known as the Matic Network, is a protocol that allows anyone to create and exchange value, powered by zero-knowledge technology. Polygon provides multiple solutions including
|
||||
|
||||
- [Polygon zkEVM](https://polygon.technology/polygon-zkevm), a zk powered EVM equivalent L2
|
||||
- [Polygon PoS](https://polygon.technology/polygon-pos), a proof of stake, EVM compatible side chain
|
||||
- [Polygon CDK](https://polygon.technology/polygon-cdk), a Chain Development Kit for building customizable zk powered L2s
|
||||
- [Polygon ID](https://polygon.technology/polygon-id), identity infrastructure and SDKs to facilitate trusted and secure relationships between apps and users
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [Polygon whitepaper](https://polygon.technology/lightpaper-polygon.pdf)
|
||||
- [Introduction to Polygon](https://wiki.polygon.technology/docs/develop/getting-started)
|
||||
- [Introduction to Polygon](https://wiki.polygon.technology/)
|
||||
- [Polygon POL whitepaper](https://polygon.technology/papers/pol-whitepaper)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user