feat: profile pages, custom roadmap pages and SSR (#5494)

* Update

* Add stats and health endpoints

* Add pre-render

* fix: redirect to the error page

* Fix generate-renderer issue

* Rename

* Fix best practice topics not loading

* Handle SSR for static pages

* Refactor faqs

* Refactor best practices

* Fix absolute import

* Fix stats

* Add custom roadmap page

* Minor UI change

* feat: custom roadmap slug routes (#4987)

* feat: replace roadmap slug

* fix: remove roadmap slug

* feat: username route

* fix: user public page

* feat: show roadmap progress

* feat: update public profile

* fix: replace with toast

* feat: user public profile page

* feat: implement profile form

* feat: implement user profile roadmap page

* refactor: remove logs

* fix: increase progress gap

* fix: remove title margin

* fix: breakpoint for roadmaps

* Update dependencies

* Upgrade dependencies

* fix: improper avatars

* fix: heatmap focus

* wip: remove `getStaticPaths`

* fix: add disable props

* wip

* feat: add email icon

* fix: update pnpm lock

* fix: implement author page

* Fix beginner roadmaps not working

* Changes to form

* Refactor profile and form

* Refactor public profile form

* Rearrange sidebar items

* Update UI for public form

* Minor text update

* Refactor public profile form

* Error page for user

* Revamp UI for profile page

* Add public profile page

* Fix vite warnings

* Add private profile banner

* feat: on blur check username

* Update fetch depth

* Add error detail

* Use hybrid mode of rendering

* Do not pre-render stats pages

* Update deployment workflow

* Update deployment workflow

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
pull/5495/head
Kamran Ahmed 7 months ago committed by GitHub
parent b029eebd7b
commit ad6002a514
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 105
      .github/workflows/deployment.yml
  2. 72
      .github/workflows/rsync-ssr.yml
  3. 10
      astro.config.mjs
  4. 5
      package.json
  5. 359
      pnpm-lock.yaml
  6. 2
      scripts/generate-renderer.sh
  7. 154
      src/api/api.ts
  8. 124
      src/api/user.ts
  9. 22
      src/components/AccountSidebar.astro
  10. 1
      src/components/Activity/ActivityPage.tsx
  11. 6
      src/components/Activity/ResourceProgress.tsx
  12. 9
      src/components/CreateTeam/RoadmapSelector.tsx
  13. 2
      src/components/CreateVersion/CreateVersion.tsx
  14. 7
      src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx
  15. 11
      src/components/CustomRoadmap/CustomRoadmap.tsx
  16. 11
      src/components/CustomRoadmap/PersonalRoadmapList.tsx
  17. 3
      src/components/CustomRoadmap/ResourceProgressStats.tsx
  18. 5
      src/components/CustomRoadmap/RoadmapHeader.tsx
  19. 2
      src/components/CustomRoadmap/SharedRoadmapList.tsx
  20. 7
      src/components/HeroSection/FavoriteRoadmaps.tsx
  21. 4
      src/components/HeroSection/HeroRoadmaps.tsx
  22. 2
      src/components/Navigation/AccountDropdownList.tsx
  23. 3
      src/components/NavigationDropdown.tsx
  24. 31
      src/components/RoadCard/SelectionButton.tsx
  25. 29
      src/components/ShareOptions/ShareOptionsModal.tsx
  26. 24
      src/components/ShareOptions/ShareSuccess.tsx
  27. 4
      src/components/TeamProgress/GroupRoadmapItem.tsx
  28. 11
      src/components/TeamProgress/TeamProgressPage.tsx
  29. 2
      src/components/TeamRoadmapsList/TeamRoadmaps.tsx
  30. 87
      src/components/UpdateProfile/ProfileUsername.tsx
  31. 113
      src/components/UpdateProfile/UpdateProfileForm.tsx
  32. 505
      src/components/UpdateProfile/UpdatePublicProfileForm.tsx
  33. 99
      src/components/UpdateProfile/VisibilityDropdown.tsx
  34. 22
      src/components/UserPublicProfile/PrivateProfileBanner.tsx
  35. 109
      src/components/UserPublicProfile/UserProfileRoadmap.tsx
  36. 146
      src/components/UserPublicProfile/UserProfileRoadmapRenderer.tsx
  37. 110
      src/components/UserPublicProfile/UserPublicActivityHeatmap.tsx
  38. 65
      src/components/UserPublicProfile/UserPublicProfileHeader.tsx
  39. 39
      src/components/UserPublicProfile/UserPublicProfilePage.tsx
  40. 70
      src/components/UserPublicProfile/UserPublicProgressStats.tsx
  41. 112
      src/components/UserPublicProfile/UserPublicProgresses.tsx
  42. 2
      src/data/roadmaps/frontend/faqs.astro
  43. 9
      src/helper/number.ts
  44. 4
      src/lib/best-practice-topic.ts
  45. 0
      src/lib/best-practice.ts
  46. 14
      src/lib/date.ts
  47. 13
      src/lib/link-group.ts
  48. 6
      src/lib/question-group.ts
  49. 8
      src/lib/roadmap.ts
  50. 25
      src/lib/video.ts
  51. 2
      src/pages/[roadmapId]/[...topicId].astro
  52. 7
      src/pages/[roadmapId]/index.astro
  53. 2
      src/pages/[roadmapId]/index.json.ts
  54. 2
      src/pages/account/update-profile.astro
  55. 2
      src/pages/authors/[authorId].astro
  56. 5
      src/pages/backend/languages.astro
  57. 10
      src/pages/best-practices/[bestPracticeId]/index.astro
  58. 2
      src/pages/best-practices/[bestPracticeId]/index.json.ts
  59. 2
      src/pages/best-practices/index.astro
  60. 2
      src/pages/index.astro
  61. 2
      src/pages/pages.json.ts
  62. 6
      src/pages/questions/[questionGroupId].astro
  63. 26
      src/pages/r/[customRoadmapSlug].astro
  64. 61
      src/pages/u/[username].astro
  65. 7
      src/pages/v1-health.ts
  66. 33
      src/pages/v1-stats.json.ts
  67. 4
      src/pages/videos/[videoId].astro

@ -1,41 +1,74 @@
name: App Deployment
name: Deploy to EC2
on:
workflow_dispatch: # allow manual run
push:
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 }}
CI: true
branches:
- master
jobs:
build:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v1
with:
node-version: 18
- 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
- name: Setup Environment
run: |
pnpm install
- name: Generate meta and build
run: |
npm run generate-renderer
npm run build
touch ./dist/.nojekyll
echo 'roadmap.sh' > ./dist/CNAME
- name: Deploy to GH Pages
run: |
git config user.email "kamranahmed.se@gmail.com"
git config user.name "Kamran Ahmed"
git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git
npm run deploy
- name: Checkout code
uses: actions/checkout@v2
with:
fetch-depth: 2
- uses: actions/setup-node@v1
with:
node-version: 20
- uses: pnpm/action-setup@v3.0.0
with:
version: 8.15.6
# --------------------
# Setup configuration
# --------------------
- name: Prepare configuration files
run: |
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/infra-config.git configuration --depth 1
- name: Copy configuration files
run: |
cp configuration/dist/github/developer-roadmap.env .env
# --------------------
# Prepare the build
# --------------------
- name: Install dependencies
run: |
pnpm install
- name: Generate build
run: |
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
npm run generate-renderer
npm run build
# --------------------
# Deploy to EC2
# --------------------
- uses: webfactory/ssh-agent@v0.7.0
with:
ssh-private-key: ${{ secrets.EC2_PRIVATE_KEY }}
- name: Deploy app to EC2
run: |
rsync -apvz --delete --no-times --exclude "configuration" -e "ssh -o StrictHostKeyChecking=no" -p ./ ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}:/var/www/roadmap.sh/
- name: Restart PM2
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_PRIVATE_KEY }}
script: |
cd /var/www/roadmap.sh
sudo pm2 restart web-roadmap
# --------------------
# Clear Cloudfront Caching
# --------------------
- name: Clear Cloudfront Caching
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/roadmapsh/infra-ansible/actions/workflows/playbook.yml/dispatches \
-d '{ "ref":"master", "inputs": { "playbook": "roadmap_web.yml", "tags": "cloudfront", "is_verbose": false } }'

@ -1,72 +0,0 @@
name: Deploy to EC2
on:
workflow_dispatch: # allow manual run
push:
branches:
- feat/ssr
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 20
- uses: pnpm/action-setup@v3.0.0
with:
version: 8.15.6
# --------------------
# Setup configuration
# --------------------
- name: Prepare configuration files
run: |
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/infra-config.git configuration --depth 1
- name: Copy configuration files
run: |
cp configuration/dist/github/developer-roadmap.env .env
# --------------------
# Prepare the build
# --------------------
- name: Install dependencies
run: |
pnpm install
- name: Generate build
run: |
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
npm run generate-renderer
npm run build
# --------------------
# Deploy to EC2
# --------------------
- uses: webfactory/ssh-agent@v0.7.0
with:
ssh-private-key: ${{ secrets.EC2_PRIVATE_KEY }}
- name: Deploy app to EC2
run: |
rsync -apvz --delete --no-times --exclude "configuration" -e "ssh -o StrictHostKeyChecking=no" -p ./ ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}:/var/www/v2.roadmap.sh/
- name: Restart PM2
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_PRIVATE_KEY }}
script: |
cd /var/www/v2.roadmap.sh
sudo pm2 restart web-roadmap
# --------------------
# Clear Cloudfront Caching
# --------------------
- name: Clear Cloudfront Caching
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/roadmapsh/infra-ansible/actions/workflows/playbook.yml/dispatches \
-d '{ "ref":"master", "inputs": { "playbook": "roadmap_web.yml", "tags": "cloudfront", "is_verbose": false } }'

@ -1,10 +1,10 @@
// https://astro.build/config
import sitemap from '@astrojs/sitemap';
import tailwind from '@astrojs/tailwind';
import node from '@astrojs/node';
import compress from 'astro-compress';
import { defineConfig } from 'astro/config';
import rehypeExternalLinks from 'rehype-external-links';
import { fileURLToPath } from 'node:url';
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
import react from '@astrojs/react';
@ -41,9 +41,11 @@ export default defineConfig({
],
],
},
build: {
format: 'file',
},
output: 'hybrid',
adapter: node({
mode: 'standalone',
}),
trailingSlash: 'never',
integrations: [
tailwind({
config: {

@ -23,6 +23,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/node": "^8.2.1",
"@astrojs/react": "^3.0.10",
"@astrojs/sitemap": "^3.0.5",
"@astrojs/tailwind": "^5.1.0",
@ -34,6 +35,7 @@
"astro": "^4.4.0",
"astro-compress": "^2.2.10",
"clsx": "^2.1.0",
"dayjs": "^1.11.10",
"dom-to-image": "^2.6.0",
"dracula-prism": "^2.1.16",
"gray-matter": "^4.0.3",
@ -48,8 +50,10 @@
"npm-check-updates": "^16.14.15",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-calendar-heatmap": "^1.9.0",
"react-confetti": "^6.1.0",
"react-dom": "^18.2.0",
"react-tooltip": "^5.26.3",
"reactflow": "^11.10.4",
"rehype-external-links": "^3.0.0",
"remark-parse": "^11.0.0",
@ -69,6 +73,7 @@
"@types/dom-to-image": "^2.6.7",
"@types/js-cookie": "^3.0.6",
"@types/prismjs": "^1.26.3",
"@types/react-calendar-heatmap": "^1.6.7",
"csv-parser": "^3.0.0",
"gh-pages": "^6.1.1",
"js-yaml": "^4.1.0",

@ -5,9 +5,12 @@ settings:
excludeLinksFromLockfile: false
dependencies:
'@astrojs/node':
specifier: ^8.2.1
version: 8.2.1(astro@4.4.0)
'@astrojs/react':
specifier: ^3.0.10
version: 3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3)
version: 3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3)
'@astrojs/sitemap':
specifier: ^3.0.5
version: 3.0.5
@ -22,10 +25,10 @@ dependencies:
version: 0.7.1(nanostores@0.9.5)(react@18.2.0)
'@resvg/resvg-js':
specifier: ^2.6.0
version: 2.6.0
version: 2.6.2
'@types/react':
specifier: ^18.2.56
version: 18.2.59
version: 18.2.58
'@types/react-dom':
specifier: ^18.2.19
version: 18.2.19
@ -38,6 +41,9 @@ dependencies:
clsx:
specifier: ^2.1.0
version: 2.1.0
dayjs:
specifier: ^1.11.10
version: 1.11.10
dom-to-image:
specifier: ^2.6.0
version: 2.6.0
@ -80,15 +86,21 @@ dependencies:
react:
specifier: ^18.2.0
version: 18.2.0
react-calendar-heatmap:
specifier: ^1.9.0
version: 1.9.0(react@18.2.0)
react-confetti:
specifier: ^6.1.0
version: 6.1.0(react@18.2.0)
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
react-tooltip:
specifier: ^5.26.3
version: 5.26.3(react-dom@18.2.0)(react@18.2.0)
reactflow:
specifier: ^11.10.4
version: 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
version: 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
rehype-external-links:
specifier: ^3.0.0
version: 3.0.0
@ -121,7 +133,7 @@ dependencies:
version: 11.0.4
zustand:
specifier: ^4.5.1
version: 4.5.1(@types/react@18.2.59)(react@18.2.0)
version: 4.5.1(@types/react@18.2.58)(react@18.2.0)
devDependencies:
'@playwright/test':
@ -139,6 +151,9 @@ devDependencies:
'@types/prismjs':
specifier: ^1.26.3
version: 1.26.3
'@types/react-calendar-heatmap':
specifier: ^1.6.7
version: 1.6.7
csv-parser:
specifier: ^3.0.0
version: 3.0.0
@ -211,6 +226,18 @@ packages:
- supports-color
dev: false
/@astrojs/node@8.2.1(astro@4.4.0):
resolution: {integrity: sha512-n3VWx34V5te6g/Jm2rbpXzTdpCW86CmstaGbsPutOs6VaXvvWwk+ZibA/bFl7XgNpxqQ5d6Pqacnsn+xkZ/Kag==}
peerDependencies:
astro: ^4.2.0
dependencies:
astro: 4.4.0
send: 0.18.0
server-destroy: 1.0.1
transitivePeerDependencies:
- supports-color
dev: false
/@astrojs/prism@3.0.0:
resolution: {integrity: sha512-g61lZupWq1bYbcBnYZqdjndShr/J3l/oFobBKPA3+qMat146zce3nz2kdO4giGbhYDt4gYdhmoBz0vZJ4sIurQ==}
engines: {node: '>=18.14.1'}
@ -218,7 +245,7 @@ packages:
prismjs: 1.29.0
dev: false
/@astrojs/react@3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3):
/@astrojs/react@3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3):
resolution: {integrity: sha512-uGRIwKMAn7tva2vxXMyoVIGxWFr0rjZ8ZWIlkTG/vIpnAjD2nM8Cz6B8j7yzj176jvl6gZ6xTbTVPm09aeK0Yw==}
engines: {node: '>=18.14.1'}
peerDependencies:
@ -227,7 +254,7 @@ packages:
react: ^17.0.2 || ^18.0.0
react-dom: ^17.0.2 || ^18.0.0
dependencies:
'@types/react': 18.2.59
'@types/react': 18.2.58
'@types/react-dom': 18.2.19
'@vitejs/plugin-react': 4.2.1(vite@5.1.3)
react: 18.2.0
@ -757,6 +784,23 @@ packages:
tslib: 2.6.2
dev: false
/@floating-ui/core@1.6.0:
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
dependencies:
'@floating-ui/utils': 0.2.1
dev: false
/@floating-ui/dom@1.6.3:
resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==}
dependencies:
'@floating-ui/core': 1.6.0
'@floating-ui/utils': 0.2.1
dev: false
/@floating-ui/utils@0.2.1:
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
dev: false
/@gar/promisify@1.1.3:
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
dev: false
@ -1135,39 +1179,39 @@ packages:
config-chain: 1.1.13
dev: false
/@reactflow/background@11.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
/@reactflow/background@11.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
classcat: 5.0.4
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/controls@11.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
/@reactflow/controls@11.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
classcat: 5.0.4
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/core@11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
/@reactflow/core@11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==}
peerDependencies:
react: '>=17'
@ -1183,19 +1227,19 @@ packages:
d3-zoom: 3.0.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/minimap@11.7.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
/@reactflow/minimap@11.7.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
'@types/d3-selection': 3.0.10
'@types/d3-zoom': 3.0.8
classcat: 5.0.4
@ -1203,48 +1247,48 @@ packages:
d3-zoom: 3.0.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/node-resizer@2.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
/@reactflow/node-resizer@2.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
classcat: 5.0.4
d3-drag: 3.0.0
d3-selection: 3.0.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/node-toolbar@1.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
/@reactflow/node-toolbar@1.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
classcat: 5.0.4
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@resvg/resvg-js-android-arm-eabi@2.6.0:
resolution: {integrity: sha512-lJnZ/2P5aMocrFMW7HWhVne5gH82I8xH6zsfH75MYr4+/JOaVcGCTEQ06XFohGMdYRP3v05SSPLPvTM/RHjxfA==}
/@resvg/resvg-js-android-arm-eabi@2.6.2:
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
@ -1252,8 +1296,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-android-arm64@2.6.0:
resolution: {integrity: sha512-N527f529bjMwYWShZYfBD60dXA4Fux+D695QsHQ93BDYZSHUoOh1CUGUyICevnTxs7VgEl98XpArmUWBZQVMfQ==}
/@resvg/resvg-js-android-arm64@2.6.2:
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
@ -1261,8 +1305,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-darwin-arm64@2.6.0:
resolution: {integrity: sha512-MabUKLVayEwlPo0mIqAmMt+qESN8LltCvv5+GLgVga1avpUrkxj/fkU1TKm8kQegutUjbP/B0QuMuUr0uhF8ew==}
/@resvg/resvg-js-darwin-arm64@2.6.2:
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@ -1270,8 +1314,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-darwin-x64@2.6.0:
resolution: {integrity: sha512-zrFetdnSw/suXjmyxSjfDV7i61hahv6DDG6kM7BYN2yJ3Es5+BZtqYZTcIWogPJedYKmzN1YTMWGd/3f0ubFiA==}
/@resvg/resvg-js-darwin-x64@2.6.2:
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@ -1279,8 +1323,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-linux-arm-gnueabihf@2.6.0:
resolution: {integrity: sha512-sH4gxXt7v7dGwjGyzLwn7SFGvwZG6DQqLaZ11MmzbCwd9Zosy1TnmrMJfn6TJ7RHezmQMgBPi18bl55FZ1AT4A==}
/@resvg/resvg-js-linux-arm-gnueabihf@2.6.2:
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
@ -1288,8 +1332,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-linux-arm64-gnu@2.6.0:
resolution: {integrity: sha512-fCyMncqCJtrlANADIduYF4IfnWQ295UKib7DAxFXQhBsM9PLDTpizr0qemZcCNadcwSVHnAIzL4tliZhCM8P6A==}
/@resvg/resvg-js-linux-arm64-gnu@2.6.2:
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -1297,8 +1341,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-linux-arm64-musl@2.6.0:
resolution: {integrity: sha512-ouLjTgBQHQyxLht4FdMPTvuY8xzJigM9EM2Tlu0llWkN1mKyTQrvYWi6TA6XnKdzDJHy7ZLpWpjZi7F5+Pg+Vg==}
/@resvg/resvg-js-linux-arm64-musl@2.6.2:
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -1306,8 +1350,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-linux-x64-gnu@2.6.0:
resolution: {integrity: sha512-n3zC8DWsvxC1AwxpKFclIPapDFibs5XdIRoV/mcIlxlh0vseW1F49b97F33BtJQRmlntsqqN6GMMqx8byB7B+Q==}
/@resvg/resvg-js-linux-x64-gnu@2.6.2:
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -1315,8 +1359,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-linux-x64-musl@2.6.0:
resolution: {integrity: sha512-n4tasK1HOlAxdTEROgYA1aCfsEKk0UOFDNd/AQTTZlTmCbHKXPq+O8npaaKlwXquxlVK8vrkcWbksbiGqbCAcw==}
/@resvg/resvg-js-linux-x64-musl@2.6.2:
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -1324,8 +1368,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-win32-arm64-msvc@2.6.0:
resolution: {integrity: sha512-X2+EoBJFwDI5LDVb51Sk7ldnVLitMGr9WwU/i21i3fAeAXZb3hM16k67DeTy16OYkT2dk/RfU1tP1wG+rWbz2Q==}
/@resvg/resvg-js-win32-arm64-msvc@2.6.2:
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@ -1333,8 +1377,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-win32-ia32-msvc@2.6.0:
resolution: {integrity: sha512-L7oevWjQoUgK5W1fCKn0euSVemhDXVhrjtwqpc7MwBKKimYeiOshO1Li1pa8bBt5PESahenhWgdB6lav9O0fEg==}
/@resvg/resvg-js-win32-ia32-msvc@2.6.2:
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
@ -1342,8 +1386,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-win32-x64-msvc@2.6.0:
resolution: {integrity: sha512-8lJlghb+Unki5AyKgsnFbRJwkEj9r1NpwyuBG8yEJiG1W9eEGl03R3I7bsVa3haof/3J1NlWf0rzSa1G++A2iw==}
/@resvg/resvg-js-win32-x64-msvc@2.6.2:
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -1351,22 +1395,22 @@ packages:
dev: false
optional: true
/@resvg/resvg-js@2.6.0:
resolution: {integrity: sha512-Tf3YpbBKcQn991KKcw/vg7vZf98v01seSv6CVxZBbRkL/xyjnoYB6KgrFL6zskT1A4dWC/vg77KyNOW+ePaNlA==}
/@resvg/resvg-js@2.6.2:
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
engines: {node: '>= 10'}
optionalDependencies:
'@resvg/resvg-js-android-arm-eabi': 2.6.0
'@resvg/resvg-js-android-arm64': 2.6.0
'@resvg/resvg-js-darwin-arm64': 2.6.0
'@resvg/resvg-js-darwin-x64': 2.6.0
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.0
'@resvg/resvg-js-linux-arm64-gnu': 2.6.0
'@resvg/resvg-js-linux-arm64-musl': 2.6.0
'@resvg/resvg-js-linux-x64-gnu': 2.6.0
'@resvg/resvg-js-linux-x64-musl': 2.6.0
'@resvg/resvg-js-win32-arm64-msvc': 2.6.0
'@resvg/resvg-js-win32-ia32-msvc': 2.6.0
'@resvg/resvg-js-win32-x64-msvc': 2.6.0
'@resvg/resvg-js-android-arm-eabi': 2.6.2
'@resvg/resvg-js-android-arm64': 2.6.2
'@resvg/resvg-js-darwin-arm64': 2.6.2
'@resvg/resvg-js-darwin-x64': 2.6.2
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
'@resvg/resvg-js-linux-arm64-gnu': 2.6.2
'@resvg/resvg-js-linux-arm64-musl': 2.6.2
'@resvg/resvg-js-linux-x64-gnu': 2.6.2
'@resvg/resvg-js-linux-x64-musl': 2.6.2
'@resvg/resvg-js-win32-arm64-msvc': 2.6.2
'@resvg/resvg-js-win32-ia32-msvc': 2.6.2
'@resvg/resvg-js-win32-x64-msvc': 2.6.2
dev: false
/@rollup/rollup-android-arm-eabi@4.9.6:
@ -1867,21 +1911,25 @@ packages:
/@types/prop-types@15.7.11:
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
dev: false
/@types/react-calendar-heatmap@1.6.7:
resolution: {integrity: sha512-xWBS9iOvw+aCidPk8QwCH69OCO7jnj6/9TjooqGQ9W+rA5m1aw36GjQMlSYKAg86otDeg9dzA+hSAIcvw/y9Rg==}
dependencies:
'@types/react': 18.2.58
dev: true
/@types/react-dom@18.2.19:
resolution: {integrity: sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==}
dependencies:
'@types/react': 18.2.59
'@types/react': 18.2.58
dev: false
/@types/react@18.2.59:
resolution: {integrity: sha512-DE+F6BYEC8VtajY85Qr7mmhTd/79rJKIHCg99MU9SWPB4xvLb6D1za2vYflgZfmPqQVEr6UqJTnLXEwzpVPuOg==}
/@types/react@18.2.58:
resolution: {integrity: sha512-TaGvMNhxvG2Q0K0aYxiKfNDS5m5ZsoIBBbtfUorxdH4NGSXIlYvZxLJI+9Dd3KjeB3780bciLyAb7ylO8pLhPw==}
dependencies:
'@types/prop-types': 15.7.11
'@types/scheduler': 0.16.8
csstype: 3.1.3
dev: false
/@types/sax@1.2.7:
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
@ -1891,7 +1939,6 @@ packages:
/@types/scheduler@0.16.8:
resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==}
dev: false
/@types/unist@2.0.10:
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
@ -2465,6 +2512,10 @@ packages:
resolution: {integrity: sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g==}
dev: false
/classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
dev: false
/clean-css@5.3.3:
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
engines: {node: '>= 10.0'}
@ -2529,7 +2580,6 @@ packages:
/color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
requiresBuild: true
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.2
@ -2709,7 +2759,6 @@ packages:
/csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
dev: false
/csv-parser@3.0.0:
resolution: {integrity: sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ==}
@ -2784,6 +2833,21 @@ packages:
d3-transition: 3.0.1(d3-selection@3.0.0)
dev: false
/dayjs@1.11.10:
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
dev: false
/debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.0.0
dev: false
/debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
@ -2834,11 +2898,21 @@ packages:
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
dev: false
/depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dev: false
/dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
dev: false
/destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dev: false
/detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
@ -2949,6 +3023,10 @@ packages:
/eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
/ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: false
/electron-to-chromium@1.4.640:
resolution: {integrity: sha512-z/6oZ/Muqk4BaE7P69bXhUhpJbUM9ZJeka43ZwxsDshKtePns4mhBlh8bU5+yrnOnz3fhG82XLzGUXazOmsWnA==}
dev: false
@ -2967,6 +3045,11 @@ packages:
/emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
/encodeurl@1.0.2:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
dev: false
/encoding@0.1.13:
resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
requiresBuild: true
@ -3066,6 +3149,11 @@ packages:
'@types/estree': 1.0.5
dev: false
/etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
dev: false
/event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
@ -3247,6 +3335,11 @@ packages:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
dev: false
/fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
dev: false
/fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
requiresBuild: true
@ -3646,6 +3739,17 @@ packages:
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
dev: false
/http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.1
toidentifier: 1.0.1
dev: false
/http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
@ -3775,7 +3879,6 @@ packages:
/is-arrayish@0.3.2:
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
requiresBuild: true
dev: false
/is-binary-path@2.1.0:
@ -4486,6 +4589,10 @@ packages:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
dev: true
/memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
dev: false
/merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
dev: false
@ -4760,6 +4867,12 @@ packages:
mime-db: 1.52.0
dev: true
/mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
dev: false
/mime@3.0.0:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
@ -4901,6 +5014,10 @@ packages:
hasBin: true
dev: false
/ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
dev: false
/ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
dev: false
@ -5185,6 +5302,13 @@ packages:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
/on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
dependencies:
ee-first: 1.1.1
dev: false
/once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies:
@ -5698,6 +5822,14 @@ packages:
sisteransi: 1.0.5
dev: false
/prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
dev: false
/property-information@6.4.0:
resolution: {integrity: sha512-9t5qARVofg2xQqKtytzt+lZ4d1Qvj8t5B8fEwXK6qOfgRLgH/b13QlgEyDh033NOS31nXeFbYv7CLUDG1CeifQ==}
dev: false
@ -5747,6 +5879,11 @@ packages:
engines: {node: '>=10'}
dev: false
/range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
dev: false
/rc-config-loader@4.1.3:
resolution: {integrity: sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==}
dependencies:
@ -5768,6 +5905,16 @@ packages:
strip-json-comments: 2.0.1
dev: false
/react-calendar-heatmap@1.9.0(react@18.2.0):
resolution: {integrity: sha512-mGed9any6QLOVckxwxC/eeP9s9wE8mTUW/FCE0V27xF9WOaCGuOftGSRH8DSDoSwgzMSVF6uuH7M1xvc+aZ8sg==}
peerDependencies:
react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
dependencies:
memoize-one: 5.2.1
prop-types: 15.8.1
react: 18.2.0
dev: false
/react-confetti@6.1.0(react@18.2.0):
resolution: {integrity: sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==}
engines: {node: '>=10.18'}
@ -5788,11 +5935,27 @@ packages:
scheduler: 0.23.0
dev: false
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
/react-refresh@0.14.0:
resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==}
engines: {node: '>=0.10.0'}
dev: false
/react-tooltip@5.26.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-MpYAws8CEHUd/RC4GaDCdoceph/T4KHM5vS5Dbk8FOmLMvvIht2ymP2htWdrke7K6lqPO8rz8+bnwWUIXeDlzg==}
peerDependencies:
react: '>=16.14.0'
react-dom: '>=16.14.0'
dependencies:
'@floating-ui/dom': 1.6.3
classnames: 2.5.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react@18.2.0:
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'}
@ -5800,18 +5963,18 @@ packages:
loose-envify: 1.4.0
dev: false
/reactflow@11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
/reactflow@11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/background': 11.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/controls': 11.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/minimap': 11.7.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/node-resizer': 2.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/node-toolbar': 1.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/background': 11.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/controls': 11.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/minimap': 11.7.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/node-resizer': 2.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
'@reactflow/node-toolbar': 1.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
@ -6186,6 +6349,27 @@ packages:
lru-cache: 6.0.0
dev: false
/send@0.18.0:
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
engines: {node: '>= 0.8.0'}
dependencies:
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
encodeurl: 1.0.2
escape-html: 1.0.3
etag: 1.8.1
fresh: 0.5.2
http-errors: 2.0.0
mime: 1.6.0
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.1
transitivePeerDependencies:
- supports-color
dev: false
/server-destroy@1.0.1:
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
dev: false
@ -6194,6 +6378,10 @@ packages:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
dev: false
/setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
dev: false
/sharp@0.32.6:
resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==}
engines: {node: '>=14.15.0'}
@ -6300,7 +6488,6 @@ packages:
/simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
requiresBuild: true
dependencies:
is-arrayish: 0.3.2
dev: false
@ -6421,6 +6608,11 @@ packages:
minipass: 3.3.6
dev: false
/statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
dev: false
/stdin-discarder@0.1.0:
resolution: {integrity: sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -6706,6 +6898,11 @@ packages:
dependencies:
is-number: 7.0.0
/toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
dev: false
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: true
@ -7228,7 +7425,7 @@ packages:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
dev: false
/zustand@4.5.1(@types/react@18.2.59)(react@18.2.0):
/zustand@4.5.1(@types/react@18.2.58)(react@18.2.0):
resolution: {integrity: sha512-XlauQmH64xXSC1qGYNv00ODaQ3B+tNPoy22jv2diYiP4eoDKr9LA+Bh5Bc3gplTrFdb6JVI+N4kc1DZ/tbtfPg==}
engines: {node: '>=12.7.0'}
peerDependencies:
@ -7243,7 +7440,7 @@ packages:
react:
optional: true
dependencies:
'@types/react': 18.2.59
'@types/react': 18.2.58
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false

@ -29,4 +29,4 @@ done
# ignore the worktree changes for the editor directory
git update-index --assume-unchanged editor/readonly-editor.tsx
git update-index --assume-unchanged editor/readonly-editor.tsx || true

@ -0,0 +1,154 @@
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../lib/jwt.ts';
import type { APIContext } from 'astro';
type HttpOptionsType = RequestInit | { headers: Record<string, any> };
type AppResponse = Record<string, any>;
export type FetchError = {
status: number;
message: string;
};
export type AppError = {
status: number;
message: string;
errors?: { message: string; location: string }[];
};
export type ApiReturn<ResponseType, ErrorType> = {
response?: ResponseType;
error?: ErrorType | FetchError;
};
export function api(context: APIContext) {
const token = context.cookies.get(TOKEN_COOKIE_NAME)?.value;
async function apiCall<ResponseType = AppResponse, ErrorType = AppError>(
url: string,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType, ErrorType>> {
try {
const response = await fetch(url, {
credentials: 'include',
...options,
headers: new Headers({
'Content-Type': 'application/json',
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options?.headers ?? {}),
}),
});
// @ts-ignore
const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html';
const data = doesAcceptHtml
? await response.text()
: await response.json();
if (response.ok) {
return {
response: data as ResponseType,
error: undefined,
};
}
// Logout user if token is invalid
if (data.status === 401) {
context.cookies.delete(TOKEN_COOKIE_NAME);
context.redirect(context.request.url);
return { response: undefined, error: data as ErrorType };
}
if (data.status === 403) {
return { response: undefined, error: data as ErrorType };
}
return {
response: undefined,
error: data as ErrorType,
};
} catch (error: any) {
return {
response: undefined,
error: {
status: 0,
message: error.message,
},
};
}
}
return {
get: function apiGet<ResponseType = AppResponse, ErrorType = AppError>(
url: string,
queryParams?: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType, ErrorType>> {
const searchParams = new URLSearchParams(queryParams).toString();
const queryUrl = searchParams ? `${url}?${searchParams}` : url;
return apiCall<ResponseType, ErrorType>(queryUrl, {
...options,
method: 'GET',
});
},
post: async function apiPost<
ResponseType = AppResponse,
ErrorType = AppError,
>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType, ErrorType>> {
return apiCall<ResponseType, ErrorType>(url, {
...options,
method: 'POST',
body: JSON.stringify(body),
});
},
patch: async function apiPatch<
ResponseType = AppResponse,
ErrorType = AppError,
>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType, ErrorType>> {
return apiCall<ResponseType, ErrorType>(url, {
...options,
method: 'PATCH',
body: JSON.stringify(body),
});
},
put: async function apiPut<
ResponseType = AppResponse,
ErrorType = AppError,
>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType, ErrorType>> {
return apiCall<ResponseType, ErrorType>(url, {
...options,
method: 'PUT',
body: JSON.stringify(body),
});
},
delete: async function apiDelete<
ResponseType = AppResponse,
ErrorType = AppError,
>(
url: string,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType, ErrorType>> {
return apiCall<ResponseType, ErrorType>(url, {
...options,
method: 'DELETE',
});
},
};
}

@ -0,0 +1,124 @@
import { type APIContext } from 'astro';
import { api } from './api.ts';
import type { ResourceType } from '../lib/resource-progress.ts';
export const allowedRoadmapVisibility = ['all', 'none', 'selected'] as const;
export type AllowedRoadmapVisibility =
(typeof allowedRoadmapVisibility)[number];
export const allowedCustomRoadmapVisibility = [
'all',
'none',
'selected',
] as const;
export type AllowedCustomRoadmapVisibility =
(typeof allowedCustomRoadmapVisibility)[number];
export const allowedProfileVisibility = ['public', 'private'] as const;
export type AllowedProfileVisibility =
(typeof allowedProfileVisibility)[number];
export interface UserDocument {
_id?: string;
name: string;
email: string;
avatar?: string;
password: string;
isEnabled: boolean;
authProvider: 'github' | 'google' | 'email' | 'linkedin';
metadata: Record<string, any>;
calculatedStats: {
activityCount: number;
totalVisitCount: number;
longestVisitStreak: number;
currentVisitStreak: number;
updatedAt: Date;
};
verificationCode: string;
resetPasswordCode: string;
isSyncedWithSendy: boolean;
links?: {
github?: string;
linkedin?: string;
twitter?: string;
website?: string;
};
username?: string;
profileVisibility: AllowedProfileVisibility;
publicConfig?: {
isAvailableForHire: boolean;
isEmailVisible: boolean;
headline: string;
roadmaps: string[];
customRoadmaps: string[];
roadmapVisibility: AllowedRoadmapVisibility;
customRoadmapVisibility: AllowedCustomRoadmapVisibility;
};
resetPasswordCodeAt: string;
verifiedAt: string;
createdAt: string;
updatedAt: string;
}
export type UserActivityCount = {
activityCount: Record<string, number>;
totalActivityCount: number;
};
type ProgressResponse = {
updatedAt: string;
title: string;
id: string;
learning: number;
skipped: number;
done: number;
total: number;
isCustomResource?: boolean;
roadmapSlug?: string;
};
export type GetPublicProfileResponse = Omit<
UserDocument,
'password' | 'verificationCode' | 'resetPasswordCode' | 'resetPasswordCodeAt'
> & {
activity: UserActivityCount;
roadmaps: ProgressResponse[];
isOwnProfile: boolean;
};
export type GetUserProfileRoadmapResponse = {
title: string;
topicCount: number;
roadmapSlug?: string;
isCustomResource?: boolean;
done: string[];
learning: string[];
skipped: string[];
nodes: any[];
edges: any[];
};
export function userApi(context: APIContext) {
return {
getPublicProfile: async function (username: string) {
return api(context).get<GetPublicProfileResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-public-profile/${username}`,
);
},
getUserProfileRoadmap: async function (
username: string,
resourceId: string,
resourceType: ResourceType = 'roadmap',
) {
return api(context).get<GetUserProfileRoadmapResponse>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-get-user-profile-roadmap/${username}`,
{
resourceId,
resourceType,
},
);
},
};
}

@ -23,6 +23,16 @@ const sidebarLinks = [
classes: 'h-3 w-4',
},
},
{
href: '/account/update-profile',
title: 'Profile',
id: 'profile',
isNew: true,
icon: {
glyph: 'user',
classes: 'h-4 w-4',
},
},
{
href: '/account/friends',
title: 'Friends',
@ -37,7 +47,7 @@ const sidebarLinks = [
href: '/account/roadmaps',
title: 'Roadmaps',
id: 'roadmaps',
isNew: true,
isNew: false,
icon: {
glyph: 'users',
classes: 'h-4 w-4',
@ -54,16 +64,6 @@ const sidebarLinks = [
classes: 'h-4 w-4',
},
},
{
href: '/account/update-profile',
title: 'Profile',
id: 'profile',
isNew: false,
icon: {
glyph: 'user',
classes: 'h-4 w-4',
},
},
{
href: '/account/settings',
title: 'Settings',

@ -14,6 +14,7 @@ type ProgressResponse = {
done: number;
total: number;
isCustomResource: boolean;
roadmapSlug?: string;
};
export type ActivityResponse = {

@ -17,6 +17,7 @@ type ResourceProgressType = {
onCleared?: () => void;
showClearButton?: boolean;
isCustomResource: boolean;
roadmapSlug?: string;
};
export function ResourceProgress(props: ResourceProgressType) {
@ -37,6 +38,7 @@ export function ResourceProgress(props: ResourceProgressType) {
doneCount,
skippedCount,
onCleared,
roadmapSlug,
} = props;
async function clearProgress() {
@ -46,7 +48,7 @@ export function ResourceProgress(props: ResourceProgressType) {
{
resourceId,
resourceType,
}
},
);
if (error || !response) {
@ -72,7 +74,7 @@ export function ResourceProgress(props: ResourceProgressType) {
: `/best-practices/${resourceId}`;
if (isCustomResource) {
url = `/r?id=${resourceId}`;
url = `/r/${roadmapSlug}`;
}
const totalMarked = doneCount + skippedCount;

@ -14,6 +14,7 @@ import { useToast } from '../../hooks/use-toast';
export type TeamResourceConfig = {
isCustomResource: boolean;
roadmapSlug?: string;
title: string;
description?: string;
visibility?: AllowedRoadmapVisibility;
@ -80,7 +81,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
{
resourceId: roadmapId,
resourceType: 'roadmap',
}
},
);
if (error || !response) {
@ -114,7 +115,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
}
},
);
if (error || !response) {
@ -312,7 +313,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
`${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${resourceId}`,
'_blank'
'_blank',
);
return;
}
@ -335,7 +336,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
)}
</div>
);
}
},
)}
</div>
)}

@ -82,7 +82,7 @@ export function CreateVersion(props: CreateVersionProps) {
return (
<div className={'flex items-center'}>
<a
href={`/r?id=${userVersion._id}`}
href={`/r/${userVersion?.slug}`}
className="flex items-center rounded-md border border-blue-400 bg-gray-50 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:hover:bg-gray-100 max-sm:hidden sm:text-sm"
>
<Map size="15px" className="mr-1.5" />

@ -27,6 +27,7 @@ export interface RoadmapDocument {
_id?: string;
title: string;
description?: string;
slug?: string;
creatorId: string;
teamId?: string;
isDiscoverable: boolean;
@ -145,7 +146,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
name="title"
id="title"
required
className="block text-black w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 text-black outline-none focus:border-black sm:text-sm"
placeholder="Enter Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
@ -165,8 +166,8 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
name="description"
required
className={cn(
'block text-black h-24 w-full resize-none rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm',
isInvalidDescription && 'border-red-300 bg-red-100'
'block h-24 w-full resize-none rounded-md border border-gray-300 px-2.5 py-2 text-black outline-none focus:border-black sm:text-sm',
isInvalidDescription && 'border-red-300 bg-red-100',
)}
placeholder="Enter Description"
value={description}

@ -56,10 +56,11 @@ export function hideRoadmapLoader() {
type CustomRoadmapProps = {
isEmbed?: boolean;
slug?: string;
};
export function CustomRoadmap(props: CustomRoadmapProps) {
const { isEmbed = false } = props;
const { isEmbed = false, slug } = props;
const { id, secret } = getUrlParams() as { id: string; secret: string };
@ -70,9 +71,11 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
async function getRoadmap() {
setIsLoading(true);
const roadmapUrl = new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`,
);
const roadmapUrl = slug
? new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap-by-slug/${slug}`,
)
: new URL(`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`);
if (secret) {
roadmapUrl.searchParams.set('secret', secret);

@ -18,7 +18,7 @@ import { PersonalRoadmapActionDropdown } from './PersonalRoadmapActionDropdown';
import type { GetRoadmapListResponse } from './RoadmapListPage';
import { useState, type Dispatch, type SetStateAction } from 'react';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
import {RoadmapIcon} from "../ReactIcons/RoadmapIcon.tsx";
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon.tsx';
type PersonalRoadmapListType = {
roadmaps: GetRoadmapListResponse['personalRoadmaps'];
@ -37,7 +37,7 @@ export function PersonalRoadmapList(props: PersonalRoadmapListType) {
async function deleteRoadmap(roadmapId: string) {
const { response, error } = await httpDelete<RoadmapDocument[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-roadmap/${roadmapId}`
`${import.meta.env.PUBLIC_API_URL}/v1-delete-roadmap/${roadmapId}`,
);
if (error || !response) {
@ -61,6 +61,7 @@ export function PersonalRoadmapList(props: PersonalRoadmapListType) {
const shareSettingsModal = selectedRoadmap && (
<ShareOptionsModal
roadmapSlug={selectedRoadmap?.slug}
isDiscoverable={selectedRoadmap.isDiscoverable}
description={selectedRoadmap.description}
visibility={selectedRoadmap.visibility}
@ -129,7 +130,7 @@ type CustomRoadmapItemProps = {
roadmap: GetRoadmapListResponse['personalRoadmaps'][number];
onRemove: (roadmapId: string) => Promise<void>;
setSelectedRoadmap: (
roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null
roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null,
) => void;
};
@ -183,9 +184,9 @@ function CustomRoadmapItem(props: CustomRoadmapItemProps) {
Edit
</a>
<a
href={`/r?id=${roadmap._id}`}
href={`/r/${roadmap?.slug}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-blue-400 bg-white px-2 py-1.5 text-xs hover:bg-blue-50 focus:outline-none text-blue-600'
'ml-2 flex items-center gap-2 rounded-md border border-blue-400 bg-white px-2 py-1.5 text-xs text-blue-600 hover:bg-blue-50 focus:outline-none'
}
target={'_blank'}
>

@ -24,6 +24,7 @@ export function ResourceProgressStats(props: ResourceProgressStatsProps) {
<>
{isSharing && $canManageCurrentRoadmap && $currentRoadmap && (
<ShareOptionsModal
roadmapSlug={$currentRoadmap?.slug}
isDiscoverable={$currentRoadmap.isDiscoverable}
description={$currentRoadmap?.description}
visibility={$currentRoadmap?.visibility}
@ -47,7 +48,7 @@ export function ResourceProgressStats(props: ResourceProgressStatsProps) {
{
'rounded-bl-md rounded-br-md': isSecondaryBanner,
'rounded-md': !isSecondaryBanner,
}
},
)}
>
<p

@ -24,6 +24,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
title,
description,
_id: roadmapId,
slug: roadmapSlug,
creator,
team,
visibility,
@ -79,6 +80,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
>
<ShareSuccess
visibility="public"
roadmapSlug={roadmapSlug}
roadmapId={roadmapId!}
description={description}
onClose={() => setIsSharingWithOthers(false)}
@ -135,7 +137,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
<ShareRoadmapButton
roadmapId={roadmapId!}
description={description!}
pageUrl={`https://roadmap.sh/r?id=${roadmapId}`}
pageUrl={`https://roadmap.sh/r/${roadmapSlug}`}
allowEmbed={true}
/>
</div>
@ -144,6 +146,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
<>
{isSharing && $currentRoadmap && (
<ShareOptionsModal
roadmapSlug={$currentRoadmap?.slug}
isDiscoverable={$currentRoadmap.isDiscoverable}
description={$currentRoadmap?.description}
visibility={$currentRoadmap?.visibility}

@ -91,7 +91,7 @@ export function SharedRoadmapList(props: SharedRoadmapListProps) {
className="relative flex w-full border-t"
>
<a
href={`/r?id=${roadmap._id}`}
href={`/r/=${roadmap?.slug}`}
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'}
>

@ -16,6 +16,7 @@ export type UserProgressResponse = {
total: number;
updatedAt: Date;
isCustomResource: boolean;
roadmapSlug?: string;
team?: {
name: string;
id: string;
@ -41,7 +42,7 @@ function renderProgress(progressList: UserProgressResponse) {
resourceType: progress.resourceType,
isFavorite: progress.isFavorite,
},
})
}),
);
const totalDone = progress.done + progress.skipped;
@ -89,7 +90,7 @@ export function FavoriteRoadmaps() {
setIsLoading(true);
const { response: progressList, error } = await httpGet<ProgressResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`,
);
if (error || !progressList) {
@ -121,7 +122,7 @@ export function FavoriteRoadmaps() {
const hasProgress = progress?.length > 0;
const customRoadmaps = progress?.filter(
(p) => p.isCustomResource && !p.team?.name
(p) => p.isCustomResource && !p.team?.name,
);
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
const teamRoadmaps: HeroTeamRoadmaps = progress

@ -172,7 +172,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
customRoadmap.total) *
100
}
url={`/r?id=${customRoadmap.resourceId}`}
url={`/r/${customRoadmap?.roadmapSlug}`}
allowFavorite={false}
/>
);
@ -242,7 +242,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
customRoadmap.total) *
100
}
url={`/r?id=${customRoadmap.resourceId}`}
url={`/r/${customRoadmap?.roadmapSlug}`}
allowFavorite={false}
/>
);

@ -20,7 +20,7 @@ export function AccountDropdownList(props: AccountDropdownListProps) {
className="group flex items-center gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<User2 className="h-4 w-4 stroke-[2.5px] text-slate-400 group-hover:text-white" />
Profile
Account
</a>
</li>
<li className="px-1">

@ -68,12 +68,13 @@ export function NavigationDropdown() {
})}
onClick={() => setIsOpen(true)}
onMouseOver={() => setIsOpen(true)}
aria-label="Open Navigation Dropdown"
>
<Menu className="h-5 w-5" />
</button>
<div
className={cn(
'absolute pointer-events-none invisible left-0 top-full z-[999] mt-2 w-48 min-w-[320px] -translate-y-1 rounded-lg bg-slate-800 py-2 opacity-0 shadow-xl transition-all duration-100',
'pointer-events-none invisible absolute left-0 top-full z-[999] mt-2 w-48 min-w-[320px] -translate-y-1 rounded-lg bg-slate-800 py-2 opacity-0 shadow-xl transition-all duration-100',
{
'pointer-events-auto visible translate-y-2.5 opacity-100': isOpen,
},

@ -1,22 +1,39 @@
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '../../lib/classname';
import type { LucideIcon } from 'lucide-react';
type SelectionButtonProps = {
icon?: LucideIcon;
text: string;
isDisabled: boolean;
isSelected: boolean;
onClick: () => void;
};
} & ButtonHTMLAttributes<HTMLButtonElement>;
export function SelectionButton(props: SelectionButtonProps) {
const { text, isDisabled, isSelected, onClick } = props;
const {
icon: Icon,
text,
isDisabled,
isSelected,
onClick,
className,
...rest
} = props;
return (
<button
className={`rounded-md border p-1 px-2 text-sm ${
isSelected ? ' border-gray-500 bg-gray-300 ' : ''
} ${
!isDisabled ? ' cursor-pointer ' : ' cursor-not-allowed opacity-40 '
}`}
{...rest}
className={cn(
'rounded-md flex items-center border p-1 px-2 text-sm',
isSelected ? 'border-gray-500 bg-gray-300' : '',
!isDisabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-40',
className,
)}
disabled={isDisabled}
onClick={onClick}
>
{Icon && <Icon size={13} className="mr-1.5" />}
{text}
</button>
);

@ -1,10 +1,4 @@
import {
type ReactNode,
useCallback,
useState,
useMemo,
useEffect,
} from 'react';
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';
@ -37,6 +31,7 @@ type ShareOptionsModalProps = {
teamId?: string;
roadmapId?: string;
description?: string;
roadmapSlug?: string;
onShareSettingsUpdate: OnShareSettingsUpdate;
};
@ -44,6 +39,7 @@ type ShareOptionsModalProps = {
export function ShareOptionsModal(props: ShareOptionsModalProps) {
const {
roadmapId,
roadmapSlug,
onClose,
isDiscoverable: defaultIsDiscoverable = false,
visibility: defaultVisibility,
@ -68,10 +64,10 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
const [visibility, setVisibility] = useState(defaultVisibility);
const [isDiscoverable, setIsDiscoverable] = useState(defaultIsDiscoverable);
const [sharedTeamMemberIds, setSharedTeamMemberIds] = useState<string[]>(
defaultSharedMemberIds
defaultSharedMemberIds,
);
const [sharedFriendIds, setSharedFriendIds] = useState<string[]>(
defaultSharedFriendIds
defaultSharedFriendIds,
);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
@ -120,7 +116,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
sharedFriendIds,
sharedTeamMemberIds,
isDiscoverable,
}
},
);
if (error) {
@ -151,7 +147,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
teamId,
sharedTeamMemberIds,
isDiscoverable,
}
},
);
if (error) {
@ -162,7 +158,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
window.location.reload();
},
[roadmapId]
[roadmapId],
);
if (isSettingsUpdated) {
@ -173,6 +169,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
bodyClassName="p-4 flex flex-col"
>
<ShareSuccess
roadmapSlug={roadmapSlug}
visibility={visibility}
roadmapId={roadmapId!}
description={description}
@ -212,11 +209,11 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
setSharedFriendIds([]);
} else if (visibility === 'friends') {
setSharedFriendIds(
defaultSharedFriendIds.length > 0 ? defaultSharedFriendIds : []
defaultSharedFriendIds.length > 0 ? defaultSharedFriendIds : [],
);
} else if (visibility === 'team' && teamId) {
setSharedTeamMemberIds(
defaultSharedMemberIds?.length > 0 ? defaultSharedMemberIds : []
defaultSharedMemberIds?.length > 0 ? defaultSharedMemberIds : [],
);
setSharedFriendIds([]);
} else {
@ -329,7 +326,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
}
onClick={() => {
handleTransferToTeam(selectedTeamId!, sharedTeamMemberIds).then(
() => null
() => null,
);
}}
>
@ -374,7 +371,7 @@ function UpdateAction(props: {
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
className,
)}
disabled={disabled}
onClick={onClick}

@ -4,6 +4,7 @@ import { cn } from '../../lib/classname';
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
type ShareSuccessProps = {
roadmapSlug?: string;
roadmapId: string;
onClose: () => void;
visibility: AllowedRoadmapVisibility;
@ -13,6 +14,7 @@ type ShareSuccessProps = {
export function ShareSuccess(props: ShareSuccessProps) {
const {
roadmapSlug,
roadmapId,
onClose,
description,
@ -23,7 +25,9 @@ export function ShareSuccess(props: ShareSuccessProps) {
const baseUrl = import.meta.env.DEV
? 'http://localhost:3000'
: 'https://roadmap.sh';
const shareLink = `${baseUrl}/r?id=${roadmapId}`;
const shareLink = roadmapSlug
? `${baseUrl}/r/${roadmapSlug}`
: `${baseUrl}/r?id=${roadmapId}`;
const { copyText, isCopied } = useCopyText();
@ -84,13 +88,13 @@ export function ShareSuccess(props: ShareSuccessProps) {
</p>
<div className="mt-2">
<input
onClick={(e) => {
e.currentTarget.select();
copyText(embedHtml);
}}
readOnly={true}
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
value={embedHtml}
onClick={(e) => {
e.currentTarget.select();
copyText(embedHtml);
}}
readOnly={true}
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
value={embedHtml}
/>
</div>
</div>
@ -127,7 +131,7 @@ export function ShareSuccess(props: ShareSuccessProps) {
<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'
isCopied && 'bg-green-300 text-green-800',
)}
disabled={isCopied}
onClick={() => {
@ -139,7 +143,7 @@ export function ShareSuccess(props: ShareSuccessProps) {
</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'
'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}
>

@ -11,7 +11,7 @@ type GroupRoadmapItemProps = {
export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
const { onShowResourceProgress } = props;
const { members, resourceTitle, resourceId, isCustomResource } =
const { members, resourceTitle, resourceId, isCustomResource, roadmapSlug } =
props.roadmap;
const { t: teamId } = getUrlParams();
@ -19,7 +19,7 @@ export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
const [showAll, setShowAll] = useState(false);
const roadmapLink = isCustomResource
? `/r?id=${resourceId}`
? `/r/${roadmapSlug}`
: `/${resourceId}?t=${teamId}`;
return (

@ -22,6 +22,7 @@ export type UserProgress = {
total: number;
updatedAt: string;
isCustomResource?: boolean;
roadmapSlug?: string;
};
export type TeamMember = {
@ -39,6 +40,7 @@ export type GroupByRoadmap = {
resourceTitle: string;
resourceType: string;
isCustomResource?: boolean;
roadmapSlug?: string;
members: {
member: TeamMember;
progress: UserProgress | undefined;
@ -71,7 +73,7 @@ export function TeamProgressPage() {
async function getTeamProgress() {
const { response, error } = await httpGet<TeamMember[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-progress/${teamId}`
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-progress/${teamId}`,
);
if (error || !response) {
toast.error(error?.message || 'Failed to get team progress');
@ -87,7 +89,7 @@ export function TeamProgressPage() {
return 1;
}
return 0;
})
}),
);
}
@ -116,7 +118,7 @@ export function TeamProgressPage() {
const members: GroupByRoadmap['members'] = [];
for (const member of teamMembers) {
const progress = member.progress.find(
(progress) => progress.resourceId === roadmap
(progress) => progress.resourceId === roadmap,
);
if (!progress) {
continue;
@ -139,6 +141,7 @@ export function TeamProgressPage() {
resourceId: roadmap,
resourceTitle: members?.[0].progress?.resourceTitle || '',
resourceType: 'roadmap',
roadmapSlug: members?.[0].progress?.roadmapSlug,
members,
isCustomResource,
});
@ -174,7 +177,7 @@ export function TeamProgressPage() {
setShowMemberProgress({
resourceId: showMemberProgress.resourceId,
member: teamMembers.find(
(member) => member.email === user?.email
(member) => member.email === user?.email,
)!,
isCustomResource: showMemberProgress.isCustomResource,
});

@ -473,7 +473,7 @@ export function TeamRoadmaps() {
)}
<a
href={`/r?id=${resourceConfig.resourceId}`}
href={`/r/${resourceConfig.roadmapSlug}`}
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'
}

@ -0,0 +1,87 @@
import { useState } from 'react';
import type { AllowedProfileVisibility } from '../../api/user';
import { httpGet, httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { CheckIcon, Loader2, X, XCircle } from 'lucide-react';
type ProfileUsernameProps = {
username: string;
setUsername: (username: string) => void;
profileVisibility: AllowedProfileVisibility;
currentUsername?: string;
};
export function ProfileUsername(props: ProfileUsernameProps) {
const { username, setUsername, profileVisibility, currentUsername } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isUnique, setIsUnique] = useState<boolean | null>(null);
const checkIsUnique = async (username: string) => {
if (isLoading || username.length < 3) {
return;
}
if (currentUsername && username === currentUsername && isUnique !== false) {
setIsUnique(true);
return;
}
setIsLoading(true);
const { response, error } = await httpPost<{
isUnique: boolean;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-check-is-unique-username`, {
username,
});
if (error || !response) {
setIsUnique(null);
setIsLoading(false);
toast.error(error?.message || 'Something went wrong. Please try again.');
return;
}
setIsUnique(response.isUnique);
setIsLoading(false);
};
return (
<div className="flex w-full flex-col">
<label htmlFor="username" className="text-sm leading-none text-slate-500">
Username
</label>
<div className="mt-2 flex items-center overflow-hidden rounded-lg border border-gray-300">
<span className="border-r border-gray-300 bg-gray-100 p-2">
roadmap.sh/u/
</span>
<div className="relative grow">
<input
type="text"
name="username"
id="username"
className="w-full px-3 py-2 outline-none placeholder:text-gray-400"
placeholder="johndoe"
spellCheck={false}
value={username}
title="Username must be at least 3 characters long and can only contain letters, numbers, and underscores"
onChange={(e) => setUsername((e.target as HTMLInputElement).value)}
onBlur={(e) => checkIsUnique((e.target as HTMLInputElement).value)}
required={profileVisibility === 'public'}
/>
<span className="absolute bottom-0 right-0 top-0 flex items-center px-2">
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isUnique === false ? (
<X className="h-4 w-4 text-red-500" />
) : isUnique === true ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : null}
</span>
</div>
</div>
</div>
);
}

@ -2,15 +2,13 @@ import { type FormEvent, useEffect, useState } from 'react';
import { httpGet, httpPost } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import UploadProfilePicture from './UploadProfilePicture';
import {ArrowDown, ChevronDown} from "lucide-react";
export function UpdateProfileForm() {
const [name, setName] = useState('');
const [avatar, setAvatar] = useState('');
const [email, setEmail] = useState('');
const [github, setGithub] = useState('');
const [twitter, setTwitter] = useState('');
const [linkedin, setLinkedin] = useState('');
const [website, setWebsite] = useState('');
const [username, setUsername] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
@ -26,10 +24,6 @@ export function UpdateProfileForm() {
`${import.meta.env.PUBLIC_API_URL}/v1-update-profile`,
{
name,
github: github || undefined,
linkedin: linkedin || undefined,
twitter: twitter || undefined,
website: website || undefined,
},
);
@ -58,14 +52,11 @@ export function UpdateProfileForm() {
return;
}
const { name, email, links, avatar } = response;
const { name, email, avatar, username } = response;
setName(name);
setEmail(email);
setGithub(links?.github || '');
setLinkedin(links?.linkedin || '');
setTwitter(links?.twitter || '');
setWebsite(links?.website || '');
setUsername(username);
setAvatar(avatar || '');
setIsLoading(false);
@ -81,8 +72,10 @@ export function UpdateProfileForm() {
return (
<div>
<div className="mb-8 hidden md:block">
<h2 className="text-3xl font-bold sm:text-4xl">Profile</h2>
<p className="mt-2 text-gray-400">Update your profile details below.</p>
<h2 className="text-2xl font-bold sm:text-3xl">Basic Information</h2>
<p className="mt-0.5 text-gray-400">
Update and set up your public profile below.
</p>
</div>
<UploadProfilePicture
type="avatar"
@ -113,12 +106,17 @@ export function UpdateProfileForm() {
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="email"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Email
</label>
<div className="flex items-center justify-between">
<label
htmlFor="email"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Email
</label>
<a href='/account/settings' className="text-purple-700 text-xs underline hover:text-purple-800">
Visit settings page to change email
</a>
</div>
<input
type="email"
name="email"
@ -131,77 +129,6 @@ export function UpdateProfileForm() {
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="github"
className="text-sm leading-none text-slate-500"
>
Github
</label>
<input
type="text"
name="github"
id="github"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://github.com/username"
value={github}
onInput={(e) => setGithub((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="twitter"
className="text-sm leading-none text-slate-500"
>
Twitter
</label>
<input
type="text"
name="twitter"
id="twitter"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://twitter.com/username"
value={twitter}
onInput={(e) => setTwitter((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="linkedin"
className="text-sm leading-none text-slate-500"
>
LinkedIn
</label>
<input
type="text"
name="linkedin"
id="linkedin"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://www.linkedin.com/in/username/"
value={linkedin}
onInput={(e) => setLinkedin((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="website"
className="text-sm leading-none text-slate-500"
>
Website
</label>
<input
type="text"
name="website"
id="website"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://example.com"
value={website}
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
/>
</div>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
)}
@ -217,7 +144,7 @@ export function UpdateProfileForm() {
disabled={isLoading}
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"
>
{isLoading ? 'Please wait...' : 'Continue'}
{isLoading ? 'Please wait...' : 'Update Information'}
</button>
</form>
</div>

@ -0,0 +1,505 @@
import { type FormEvent, useEffect, useState } from 'react';
import { httpGet, httpPatch } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import type {
AllowedCustomRoadmapVisibility,
AllowedProfileVisibility,
AllowedRoadmapVisibility,
UserDocument,
} from '../../api/user';
import { SelectionButton } from '../RoadCard/SelectionButton';
import { ArrowUpRight, Eye, EyeOff } from 'lucide-react';
import { useToast } from '../../hooks/use-toast';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
import { VisibilityDropdown } from './VisibilityDropdown.tsx';
import { ProfileUsername } from './ProfileUsername.tsx';
type RoadmapType = {
id: string;
title: string;
isCustomResource: boolean;
};
type GetProfileSettingsResponse = Pick<
UserDocument,
'username' | 'profileVisibility' | 'publicConfig' | 'links'
>;
export function UpdatePublicProfileForm() {
const [profileVisibility, setProfileVisibility] =
useState<AllowedProfileVisibility>('private');
const toast = useToast();
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [publicProfileUrl, setPublicProfileUrl] = useState('');
const [isAvailableForHire, setIsAvailableForHire] = useState(false);
const [isEmailVisible, setIsEmailVisible] = useState(true);
const [headline, setHeadline] = useState('');
const [username, setUsername] = useState('');
const [roadmapVisibility, setRoadmapVisibility] =
useState<AllowedRoadmapVisibility>('all');
const [customRoadmapVisibility, setCustomRoadmapVisibility] =
useState<AllowedCustomRoadmapVisibility>('all');
const [roadmaps, setRoadmaps] = useState<string[]>([]);
const [customRoadmaps, setCustomRoadmaps] = useState<string[]>([]);
const [currentUsername, setCurrentUsername] = useState('');
const [github, setGithub] = useState('');
const [twitter, setTwitter] = useState('');
const [linkedin, setLinkedin] = useState('');
const [website, setWebsite] = useState('');
const [profileRoadmaps, setProfileRoadmaps] = useState<RoadmapType[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-public-profile-config`,
{
isAvailableForHire,
isEmailVisible,
profileVisibility,
headline,
username,
roadmapVisibility,
customRoadmapVisibility,
roadmaps,
customRoadmaps,
github,
twitter,
linkedin,
website,
},
);
if (error || !response) {
setIsLoading(false);
toast.error(error?.message || 'Something went wrong');
return;
}
await loadProfileSettings();
toast.success('Profile updated successfully');
};
const loadProfileSettings = async () => {
setIsLoading(true);
const { error, response } = await httpGet<UserDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-profile-settings`,
);
if (error || !response) {
setIsLoading(false);
toast.error(error?.message || 'Something went wrong');
return;
}
const {
links,
username,
profileVisibility: defaultProfileVisibility,
publicConfig,
} = response;
setPublicProfileUrl(username ? `/u/${username}` : '');
setUsername(username || '');
setCurrentUsername(username || '');
setGithub(links?.github || '');
setTwitter(links?.twitter || '');
setLinkedin(links?.linkedin || '');
setWebsite(links?.website || '');
setProfileVisibility(defaultProfileVisibility || 'private');
setHeadline(publicConfig?.headline || '');
setRoadmapVisibility(publicConfig?.roadmapVisibility || 'none');
setCustomRoadmapVisibility(publicConfig?.customRoadmapVisibility || 'none');
setCustomRoadmaps(publicConfig?.customRoadmaps || []);
setRoadmaps(publicConfig?.roadmaps || []);
setCustomRoadmapVisibility(publicConfig?.customRoadmapVisibility || 'none');
setIsAvailableForHire(publicConfig?.isAvailableForHire || false);
setIsEmailVisible(publicConfig?.isEmailVisible ?? true);
setIsLoading(false);
};
const loadProfileRoadmaps = async () => {
setIsLoading(true);
const { error, response } = await httpGet<{
roadmaps: RoadmapType[];
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-profile-roadmaps`);
if (error || !response) {
setIsLoading(false);
toast.error(error?.message || 'Something went wrong');
return;
}
setProfileRoadmaps(response?.roadmaps || []);
setIsLoading(false);
};
const updateProfileVisibility = async (
visibility: AllowedProfileVisibility,
) => {
pageProgressMessage.set('Updating profile visibility');
setIsLoading(true);
const { error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-public-profile-visibility`,
{
profileVisibility: visibility,
},
);
if (error) {
setIsLoading(false);
toast.error(error.message || 'Something went wrong');
return;
}
setProfileVisibility(visibility);
setIsLoading(false);
pageProgressMessage.set('');
};
// Make a request to the backend to fill in the form with the current values
useEffect(() => {
Promise.all([loadProfileSettings(), loadProfileRoadmaps()]).finally(() => {
pageProgressMessage.set('');
});
}, []);
const publicCustomRoadmaps = profileRoadmaps.filter(
(r) => r.isCustomResource,
);
const publicRoadmaps = profileRoadmaps.filter((r) => !r.isCustomResource);
return (
<div className="-mx-10 mt-10 border-t px-10 pt-10">
{isCreatingRoadmap && (
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
)}
<div className="mb-1 flex flex-col justify-between gap-2 sm:flex-row">
<div className="flex flex-grow flex-col items-start gap-2 sm:flex-row">
<h3 className="mr-1 text-xl font-bold sm:text-3xl">
Personal Profile
</h3>
{publicProfileUrl && (
<a
href={publicProfileUrl}
target="_blank"
className="flex h-[30px] shrink-0 flex-row items-center gap-1 rounded-lg border border-black pl-1.5 pr-2.5 text-sm transition-colors hover:bg-black hover:text-white"
>
<ArrowUpRight className="h-3 w-3 stroke-[3]" />
Visit
</a>
)}
</div>
<VisibilityDropdown
visibility={profileVisibility}
setVisibility={setProfileVisibility}
/>
</div>
<p className="mt-2 hidden text-sm text-gray-400 sm:mt-0 sm:block sm:text-base">
Set up your public profile to showcase your learning progress.
</p>
<form className="mt-6 space-y-4 pb-10" onSubmit={handleSubmit}>
<div className="flex w-full flex-col">
<label
htmlFor="headline"
className="text-sm leading-none text-slate-500"
>
Headline
</label>
<input
type="text"
name="headline"
id="headline"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Full Stack Developer"
value={headline}
onChange={(e) => setHeadline((e.target as HTMLInputElement).value)}
required={profileVisibility === 'public'}
/>
</div>
<ProfileUsername
username={username}
setUsername={setUsername}
profileVisibility={profileVisibility}
currentUsername={currentUsername}
/>
<div className="rounded-md border p-4">
<h3 className="text-sm font-medium">
Which roadmap progresses do you want to show on your profile?
</h3>
<div className="mt-3 flex flex-wrap items-center gap-2">
<SelectionButton
type="button"
text="All Progress"
icon={Eye}
isDisabled={false}
isSelected={roadmapVisibility === 'all'}
onClick={() => {
setRoadmapVisibility('all');
setRoadmaps([]);
}}
/>
<SelectionButton
type="button"
icon={EyeOff}
text="Hide my Progress"
isDisabled={false}
isSelected={roadmapVisibility === 'none'}
onClick={() => {
setRoadmapVisibility('none');
setRoadmaps([]);
}}
/>
</div>
<h3 className="mt-4 text-sm text-gray-400">
Or select the roadmaps you want to show
</h3>
{publicRoadmaps.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{publicRoadmaps.map((r) => (
<SelectionButton
type="button"
key={r.id}
text={r.title}
isDisabled={false}
isSelected={roadmaps.includes(r.id)}
onClick={() => {
if (roadmapVisibility !== 'selected') {
setRoadmapVisibility('selected');
}
if (roadmaps.includes(r.id)) {
setRoadmaps(roadmaps.filter((id) => id !== r.id));
} else {
setRoadmaps([...roadmaps, r.id]);
}
}}
/>
))}
</div>
) : (
<p className="mt-2 rounded-lg bg-yellow-100 p-2 text-sm text-yellow-700">
Update{' '}
<a
target="_blank"
className="font-medium underline underline-offset-2 hover:text-yellow-800"
href="/roadmaps"
>
your progress on roadmaps
</a>{' '}
to show your learning activity.
</p>
)}
</div>
<div className="rounded-md border p-4">
<h3 className="text-sm font-medium">
Pick your custom roadmaps to show on your profile
</h3>
<div className="mt-3 flex flex-wrap items-center gap-2">
<SelectionButton
type="button"
text="All Roadmaps"
icon={Eye}
isDisabled={false}
isSelected={customRoadmapVisibility === 'all'}
onClick={() => {
setCustomRoadmapVisibility('all');
setCustomRoadmaps([]);
}}
/>
<SelectionButton
type="button"
text="Hide my Roadmaps"
icon={EyeOff}
isDisabled={false}
isSelected={customRoadmapVisibility === 'none'}
onClick={() => {
setCustomRoadmapVisibility('none');
setCustomRoadmaps([]);
}}
/>
</div>
<h3 className="mt-4 text-sm text-gray-400">
Or select the custom roadmaps you want to show
</h3>
{publicCustomRoadmaps.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{publicCustomRoadmaps.map((r) => (
<SelectionButton
type="button"
key={r.id}
text={r.title}
isDisabled={false}
isSelected={customRoadmaps.includes(r.id)}
onClick={() => {
if (customRoadmapVisibility !== 'selected') {
setCustomRoadmapVisibility('selected');
}
if (customRoadmaps.includes(r.id)) {
setCustomRoadmaps(
customRoadmaps.filter((id) => id !== r.id),
);
} else {
setCustomRoadmaps([...customRoadmaps, r.id]);
}
}}
/>
))}
</div>
) : (
<p className="mt-2 rounded-lg bg-yellow-100 p-2 text-sm text-yellow-700">
You do not have any custom roadmaps.{' '}
<button
type={'button'}
className="font-medium underline underline-offset-2 hover:text-yellow-800"
onClick={() => {
setIsCreatingRoadmap(true);
}}
>
Create one now
</button>
.
</p>
)}
</div>
<div className="flex w-full flex-col">
<label
htmlFor="github"
className="text-sm leading-none text-slate-500"
>
Github
</label>
<input
type="text"
name="github"
id="github"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://github.com/username"
value={github}
onChange={(e) => setGithub((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="twitter"
className="text-sm leading-none text-slate-500"
>
Twitter
</label>
<input
type="text"
name="twitter"
id="twitter"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://twitter.com/username"
value={twitter}
onChange={(e) => setTwitter((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="linkedin"
className="text-sm leading-none text-slate-500"
>
LinkedIn
</label>
<input
type="text"
name="linkedin"
id="linkedin"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://www.linkedin.com/in/username/"
value={linkedin}
onChange={(e) => setLinkedin((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="website"
className="text-sm leading-none text-slate-500"
>
Website
</label>
<input
type="text"
name="website"
id="website"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://example.com"
value={website}
onChange={(e) => setWebsite((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex select-none items-center gap-2 rounded-md border px-2 hover:bg-gray-100">
<input
type="checkbox"
name="isEmailVisible"
id="isEmailVisible"
checked={isEmailVisible}
onChange={(e) => setIsEmailVisible(e.target.checked)}
/>
<label
htmlFor="isEmailVisible"
className="flex-grow cursor-pointer py-1.5"
>
Make my email public
</label>
</div>
<div className="flex select-none items-center gap-2 rounded-md border px-2 hover:bg-gray-100">
<input
type="checkbox"
name="isAvailableForHire"
id="isAvailableForHire"
checked={isAvailableForHire}
onChange={(e) => setIsAvailableForHire(e.target.checked)}
/>
<label
htmlFor="isAvailableForHire"
className="flex-grow cursor-pointer py-1.5"
>
Available for Hire
</label>
</div>
</div>
<button
type="submit"
disabled={isLoading}
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"
>
{isLoading ? 'Please wait...' : 'Update Public Profile'}
</button>
</form>
</div>
);
}

@ -0,0 +1,99 @@
import { ChevronDown, Globe, LockIcon } from 'lucide-react';
import { type AllowedProfileVisibility } from '../../api/user.ts';
import { pageProgressMessage } from '../../stores/page.ts';
import { httpPatch } from '../../lib/http.ts';
import { useToast } from '../../hooks/use-toast.ts';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click.ts';
import { cn } from '../../lib/classname.ts';
type VisibilityDropdownProps = {
visibility: AllowedProfileVisibility;
setVisibility: (visibility: AllowedProfileVisibility) => void;
};
export function VisibilityDropdown(props: VisibilityDropdownProps) {
const { visibility, setVisibility } = props;
const toast = useToast();
const dropdownRef = useRef<HTMLDivElement>(null);
useOutsideClick(dropdownRef, () => {
setIsVisibilityDropdownOpen(false);
});
const [isVisibilityDropdownOpen, setIsVisibilityDropdownOpen] =
useState(false);
async function updateProfileVisibility(visibility: AllowedProfileVisibility) {
pageProgressMessage.set('Updating profile visibility');
setIsVisibilityDropdownOpen(false);
const { error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-public-profile-visibility`,
{
profileVisibility: visibility,
},
);
if (error) {
toast.error(error.message || 'Something went wrong');
return;
}
pageProgressMessage.set('');
setVisibility(visibility);
}
return (
<div className="relative">
<button
onClick={() => {
setIsVisibilityDropdownOpen(true);
}}
className={cn(
'flex items-center gap-1 rounded-lg border border-black py-1 pl-1.5 pr-2 text-sm capitalize text-black',
{
invisible: isVisibilityDropdownOpen,
},
)}
>
{visibility === 'public' && <Globe className='mr-1' size={13} />}
{visibility === 'private' && <LockIcon className='mr-1' size={13} />}
{visibility}
<ChevronDown size={13} className="ml-1" />
</button>
{isVisibilityDropdownOpen && (
<div
className="absolute right-0 top-0 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg"
ref={dropdownRef}
>
<button
className={cn(
'flex w-full items-center gap-2 py-2.5 pl-3 pr-3.5 text-left text-sm hover:bg-gray-100',
{
'bg-gray-200': visibility === 'public',
},
)}
onClick={() => updateProfileVisibility('public')}
>
<Globe size={13} />
Public
</button>
<button
className={cn(
'flex w-full items-center gap-2 py-2.5 pl-3 pr-3.5 text-left text-sm hover:bg-gray-100',
{
'bg-gray-200': visibility === 'private',
},
)}
onClick={() => updateProfileVisibility('private')}
>
<LockIcon size={13} />
Private
</button>
</div>
)}
</div>
);
}

@ -0,0 +1,22 @@
import type { GetPublicProfileResponse } from '../../api/user';
import { Lock } from 'lucide-react';
type PrivateProfileBannerProps = Pick<
GetPublicProfileResponse,
'isOwnProfile' | 'profileVisibility'
>;
export function PrivateProfileBanner(props: PrivateProfileBannerProps) {
const { isOwnProfile, profileVisibility } = props;
if (isOwnProfile && profileVisibility === 'private') {
return (
<div className="-mb-4 -mt-5 rounded-lg border border-yellow-400 bg-yellow-100 p-2 text-center text-sm font-medium">
<Lock className="-mt-1 mr-1.5 inline-block h-4 w-4" />
Your profile is private. Only you can see this page.
</div>
);
}
return null;
}

@ -0,0 +1,109 @@
import type {
GetUserProfileRoadmapResponse,
GetPublicProfileResponse,
} from '../../api/user';
import { getPercentage } from '../../helper/number';
import { PrivateProfileBanner } from './PrivateProfileBanner';
import { UserProfileRoadmapRenderer } from './UserProfileRoadmapRenderer';
type UserProfileRoadmapProps = GetUserProfileRoadmapResponse &
Pick<
GetPublicProfileResponse,
'username' | 'name' | 'isOwnProfile' | 'profileVisibility'
> & {
resourceId: string;
};
export function UserProfileRoadmap(props: UserProfileRoadmapProps) {
const {
username,
name,
title,
resourceId,
isCustomResource,
done = [],
skipped = [],
learning = [],
topicCount,
isOwnProfile,
profileVisibility,
} = props;
const trackProgressRoadmapUrl = isCustomResource
? `/r/${resourceId}`
: `/${resourceId}`;
const totalMarked = done.length + skipped.length;
const progressPercentage = getPercentage(totalMarked, topicCount);
return (
<>
<PrivateProfileBanner
isOwnProfile={isOwnProfile}
profileVisibility={profileVisibility}
/>
<div className="container mt-5">
<div className="flex items-center justify-between gap-2">
<p className="flex items-center gap-1 text-sm">
<a
href={`/u/${username}`}
className="text-gray-600 hover:text-gray-800"
>
{username}
</a>
<span>/</span>
<a
href={`/u/${username}/${resourceId}`}
className="text-gray-600 hover:text-gray-800"
>
{resourceId}
</a>
</p>
<a
href={trackProgressRoadmapUrl}
className="rounded-md border px-2.5 py-1 text-sm font-medium"
>
Track your Progress
</a>
</div>
<h2 className="mt-10 text-2xl font-bold sm:text-4xl">{title}</h2>
<p className="mt-2 text-sm text-gray-500 sm:text-lg">
Skills {name} has mastered on the {title?.toLowerCase()}.
</p>
</div>
<div className="relative z-50 mt-10 hidden items-center justify-between border-y bg-white px-2 py-1.5 sm:flex">
<p className="container flex text-sm">
<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="">{progressPercentage}</span>% Done
</span>
<span className="itesm-center hidden md:flex">
<span>
<span>{done.length}</span> completed
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span>{learning.length}</span> in progress
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span>{skipped.length}</span> skipped
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span>{topicCount}</span> Total
</span>
</span>
<span className="md:hidden">
<span>{totalMarked}</span> of <span>{topicCount}</span> Done
</span>
</p>
</div>
<UserProfileRoadmapRenderer {...props} resourceType="roadmap" />
</>
);
}

@ -0,0 +1,146 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import '../FrameRenderer/FrameRenderer.css';
import { Spinner } from '../ReactIcons/Spinner';
import {
renderTopicProgress,
topicSelectorAll,
} from '../../lib/resource-progress';
import { useToast } from '../../hooks/use-toast';
import { replaceChildren } from '../../lib/dom.ts';
import type { GetUserProfileRoadmapResponse } from '../../api/user.ts';
import { ReadonlyEditor } from '../../../editor/readonly-editor.tsx';
import { cn } from '../../lib/classname.ts';
export type UserProfileRoadmapRendererProps = GetUserProfileRoadmapResponse & {
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
};
export function UserProfileRoadmapRenderer(
props: UserProfileRoadmapRendererProps,
) {
const {
resourceId,
resourceType,
done,
skipped,
learning,
edges,
nodes,
isCustomResource,
} = props;
const containerEl = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(!isCustomResource);
const toast = useToast();
let resourceJsonUrl = 'https://roadmap.sh';
if (resourceType === 'roadmap') {
resourceJsonUrl += `/${resourceId}.json`;
} else {
resourceJsonUrl += `/best-practices/${resourceId}.json`;
}
async function renderResource(jsonUrl: string) {
const res = await fetch(jsonUrl, {});
const json = await res.json();
const { wireframeJSONToSVG } = await import('roadmap-renderer');
const svg: SVGElement | null = await wireframeJSONToSVG(json, {
fontURL: '/fonts/balsamiq.woff2',
});
replaceChildren(containerEl.current!, svg);
}
useEffect(() => {
if (
!containerEl.current ||
!resourceJsonUrl ||
!resourceId ||
!resourceType ||
isCustomResource
) {
return;
}
setIsLoading(true);
renderResource(resourceJsonUrl)
.then(() => {
done.forEach((id: string) => renderTopicProgress(id, 'done'));
learning.forEach((id: string) => renderTopicProgress(id, 'learning'));
skipped.forEach((id: string) => renderTopicProgress(id, 'skipped'));
setIsLoading(false);
})
.catch((err) => {
console.error(err);
toast.error(err?.message || 'Something went wrong. Please try again!');
})
.finally(() => {
setIsLoading(false);
});
}, []);
return (
<div id="customized-roadmap">
<div
className={cn(
'bg-white',
isCustomResource ? 'w-full' : 'container relative !max-w-[1000px]',
)}
>
{isCustomResource ? (
<ReadonlyEditor
roadmap={{
nodes,
edges,
}}
className="min-h-[1000px]"
onRendered={(wrapperRef: RefObject<HTMLDivElement>) => {
done?.forEach((topicId: string) => {
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
(el) => {
el.classList.add('done');
},
);
});
learning?.forEach((topicId: string) => {
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
(el) => {
el.classList.add('learning');
},
);
});
skipped?.forEach((topicId: string) => {
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
(el) => {
el.classList.add('skipped');
},
);
});
}}
fontFamily="Balsamiq Sans"
fontURL="/fonts/balsamiq.woff2"
/>
) : (
<div
id={'resource-svg-wrap'}
ref={containerEl}
className="pointer-events-none px-4 pb-2"
/>
)}
{isLoading && (
<div className="flex w-full justify-center">
<Spinner
isDualRing={false}
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"
/>
</div>
)}
</div>
</div>
);
}

@ -0,0 +1,110 @@
import CalendarHeatmap from 'react-calendar-heatmap';
import { Tooltip as ReactTooltip } from 'react-tooltip';
import 'react-calendar-heatmap/dist/styles.css';
import 'react-tooltip/dist/react-tooltip.css';
import { formatActivityDate, formatMonthDate } from '../../lib/date';
import type { UserActivityCount } from '../../api/user';
import dayjs from 'dayjs';
type UserActivityHeatmapProps = {
activity: UserActivityCount;
joinedAt: string;
};
const legends = [
{ count: '1-2', color: 'bg-gray-200' },
{ count: '3-4', color: 'bg-gray-300' },
{ count: '5-9', color: 'bg-gray-500' },
{ count: '10-19', color: 'bg-gray-600' },
{ count: '20+', color: 'bg-gray-800' },
];
export function UserActivityHeatmap(props: UserActivityHeatmapProps) {
const { activity } = props;
const data = Object.entries(activity.activityCount).map(([date, count]) => ({
date,
count,
}));
const startDate = dayjs().subtract(1, 'year').toDate();
const endDate = dayjs().toDate();
return (
<div className="rounded-lg border bg-white p-4">
<div className="-mx-4 mb-8 flex justify-between border-b px-4 pb-3">
<div className="">
<h2 className="mb-0.5 font-semibold">Activity</h2>
<p className="text-sm text-gray-500">
Progress updates over the past year
</p>
</div>
<span className="text-sm text-gray-400">
Member since: {formatMonthDate(props.joinedAt)}
</span>
</div>
<CalendarHeatmap
startDate={startDate}
endDate={endDate}
values={data}
classForValue={(value) => {
if (!value) {
return 'fill-gray-100 rounded-md [rx:2px] focus:outline-none';
}
const { count } = value;
if (count >= 20) {
return 'fill-gray-800 rounded-md [rx:2px] focus:outline-none';
} else if (count >= 10) {
return 'fill-gray-600 rounded-md [rx:2px] focus:outline-none';
} else if (count >= 5) {
return 'fill-gray-500 rounded-md [rx:2px] focus:outline-none';
} else if (count >= 3) {
return 'fill-gray-300 rounded-md [rx:2px] focus:outline-none';
} else {
return 'fill-gray-200 rounded-md [rx:2px] focus:outline-none';
}
}}
tooltipDataAttrs={(value: any) => {
if (!value || !value.date) {
return null;
}
const formattedDate = formatActivityDate(value.date);
return {
'data-tooltip-id': 'user-activity-tip',
'data-tooltip-content': `${value.count} Updates - ${formattedDate}`,
};
}}
/>
<ReactTooltip
id="user-activity-tip"
className="!rounded-lg !bg-gray-900 !p-1 !px-2 !text-sm"
/>
<div className="mt-4 flex items-center justify-between">
<span className="text-sm text-gray-400">
Number of topics marked as learning, or completed by day
</span>
<div className="flex items-center">
<span className="mr-2 text-xs text-gray-500">Less</span>
{legends.map((legend) => (
<div
key={legend.count}
className="flex items-center"
data-tooltip-id="user-activity-tip"
data-tooltip-content={`${legend.count} Updates`}
>
<div className={`h-3 w-3 ${legend.color} mr-1 rounded-sm`}></div>
</div>
))}
<span className="ml-2 text-xs text-gray-500">More</span>
<ReactTooltip
id="user-activity-tip"
className="!rounded-lg !bg-gray-900 !p-1 !px-2 !text-sm"
/>
</div>
</div>
</div>
);
}

@ -0,0 +1,65 @@
import { Github, Globe, LinkedinIcon, Mail, Twitter } from 'lucide-react';
import type { GetPublicProfileResponse } from '../../api/user';
type UserPublicProfileHeaderProps = {
userDetails: GetPublicProfileResponse;
};
export function UserPublicProfileHeader(props: UserPublicProfileHeaderProps) {
const { userDetails } = props;
const { name, links, publicConfig, avatar, email } = userDetails;
const { headline, isAvailableForHire, isEmailVisible } = publicConfig!;
return (
<div className="flex items-center gap-6 container bg-white border p-8 rounded-xl">
<img
src={
avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
: '/images/default-avatar.png'
}
alt={name}
className="h-32 w-32 rounded-full"
/>
<div>
{isAvailableForHire && (
<span className="mb-1 inline-block rounded-md bg-green-100 px-2 py-1 text-sm text-green-700">
Available for hire
</span>
)}
<h1 className="text-3xl font-bold">{name}</h1>
<p className="mt-1 text-base text-gray-500">{headline}</p>
<div className="mt-3 flex items-center gap-2">
{links?.github && <UserLink href={links?.github} icon={Github} />}
{links?.linkedin && (
<UserLink href={links?.linkedin} icon={LinkedinIcon} />
)}
{links?.twitter && <UserLink href={links?.twitter} icon={Twitter} />}
{links?.website && <UserLink href={links?.website} icon={Globe} />}
{isEmailVisible && <UserLink href={`mailto:${email}`} icon={Mail} />}
</div>
</div>
</div>
);
}
type UserLinkProps = {
href: string;
icon: typeof Github;
};
export function UserLink(props: UserLinkProps) {
const { href, icon: Icon } = props;
return (
<a
target="_blank"
href={href}
className="flex h-6 w-6 items-center justify-center rounded-md border"
>
<Icon className="h-3.5 w-3.5 shrink-0 stroke-2" />
</a>
);
}

@ -0,0 +1,39 @@
import type { GetPublicProfileResponse } from '../../api/user';
import { PrivateProfileBanner } from './PrivateProfileBanner';
import { UserActivityHeatmap } from './UserPublicActivityHeatmap';
import { UserPublicProfileHeader } from './UserPublicProfileHeader';
import { UserPublicProgresses } from './UserPublicProgresses';
type UserPublicProfilePageProps = GetPublicProfileResponse;
export function UserPublicProfilePage(props: UserPublicProfilePageProps) {
const {
activity,
username,
isOwnProfile,
profileVisibility,
_id: userId,
createdAt,
} = props;
return (
<div className="bg-gray-200/40 min-h-full flex-grow pt-10 pb-36">
<div className="container flex flex-col gap-8">
<PrivateProfileBanner
isOwnProfile={isOwnProfile}
profileVisibility={profileVisibility}
/>
<UserPublicProfileHeader userDetails={props!} />
<UserActivityHeatmap joinedAt={createdAt} activity={activity!} />
<UserPublicProgresses
username={username!}
userId={userId!}
roadmaps={props.roadmaps}
publicConfig={props.publicConfig}
/>
</div>
</div>
);
}

@ -0,0 +1,70 @@
import { getPercentage } from '../../helper/number';
import { getRelativeTimeString } from '../../lib/date';
type UserPublicProgressStats = {
resourceType: 'roadmap';
resourceId: string;
title: string;
updatedAt: string;
totalCount: number;
doneCount: number;
learningCount: number;
skippedCount: number;
showClearButton?: boolean;
isCustomResource?: boolean;
roadmapSlug?: string;
username: string;
userId: string;
};
export function UserPublicProgressStats(props: UserPublicProgressStats) {
const {
updatedAt,
resourceId,
title,
totalCount,
learningCount,
doneCount,
skippedCount,
roadmapSlug,
isCustomResource = false,
username,
userId,
} = props;
// Currently we only support roadmap not (best-practices)
const url = isCustomResource
? `/r/${roadmapSlug}`
: `/${resourceId}?s=${userId}`;
const totalMarked = doneCount + skippedCount;
const progressPercentage = getPercentage(totalMarked, totalCount);
return (
<a
href={url}
target="_blank"
className="group block rounded-md border p-2.5"
>
<h3 className="flex-1 cursor-pointer truncate text-lg font-medium">
{title}
</h3>
<div className="relative mt-5 h-1 w-full overflow-hidden rounded-full bg-black/5">
<div
className={`absolute left-0 top-0 h-full bg-black/40`}
style={{
width: `${progressPercentage}%`,
}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-sm text-gray-600">
{progressPercentage}% completed
</span>
<span className="text-sm text-gray-400">
Last updated {getRelativeTimeString(updatedAt)}
</span>
</div>
</a>
);
}

@ -0,0 +1,112 @@
import type { GetPublicProfileResponse } from '../../api/user';
import { UserPublicProgressStats } from './UserPublicProgressStats';
import { getPercentage } from '../../helper/number.ts';
type UserPublicProgressesProps = {
userId: string;
username: string;
roadmaps: GetPublicProfileResponse['roadmaps'];
publicConfig: GetPublicProfileResponse['publicConfig'];
};
export function UserPublicProgresses(props: UserPublicProgressesProps) {
const {
roadmaps: roadmapProgresses = [],
username,
publicConfig,
userId,
} = props;
const { roadmapVisibility, customRoadmapVisibility } = publicConfig! || {};
const roadmaps = roadmapProgresses.filter(
(roadmap) => !roadmap.isCustomResource,
);
const customRoadmaps = roadmapProgresses.filter(
(roadmap) => roadmap.isCustomResource,
);
// <UserPublicProgressStats
// updatedAt={roadmap.updatedAt}
// title={roadmap.title}
// totalCount={roadmap.total}
// doneCount={roadmap.done}
// learningCount={roadmap.learning}
// skippedCount={roadmap.skipped}
// resourceId={roadmap.id}
// resourceType="roadmap"
// roadmapSlug={roadmap.roadmapSlug}
// username={username!}
// isCustomResource={true}
// userId={userId}
// />
return (
<div>
{customRoadmapVisibility !== 'none' && customRoadmaps?.length > 0 && (
<div className="mb-5">
<h2 className="mb-2 text-xs uppercase tracking-wide text-gray-400">
Roadmaps made by me
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3">
{customRoadmaps.map((roadmap, counter) => {
const doneCount = roadmap.done;
const skippedCount = roadmap.skipped;
const totalCount = roadmap.total;
const totalMarked = doneCount + skippedCount;
const progressPercentage = getPercentage(totalMarked, totalCount);
return (
<a
target="_blank"
href={`/r/${roadmap.roadmapSlug}`}
key={roadmap.id + counter}
className="rounded-md border bg-white px-3 py-2 text-left text-sm shadow-sm transition-all hover:border-gray-300 hover:bg-gray-50"
>
{roadmap.title}
</a>
);
})}
</div>
</div>
)}
{roadmapVisibility !== 'none' && roadmaps.length > 0 && (
<>
<h2 className="mb-2 text-xs uppercase tracking-wide text-gray-400">
Skills I have mastered
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3">
{roadmaps.map((roadmap, counter) => {
const percentageDone = getPercentage(
roadmap.done + roadmap.skipped,
roadmap.total,
);
return (
<a
target="_blank"
key={roadmap.id + counter}
href={`/${roadmap.id}?s=${userId}`}
className="relative group border-gray-300 flex items-center justify-between rounded-md border bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400 overflow-hidden"
>
<span className="flex-grow truncate">{roadmap.title}</span>
<span className="text-xs text-gray-400">
{parseInt(percentageDone, 10)}%
</span>
<span
className="absolute transition-colors left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10"
style={{
width: `${percentageDone}%`,
}}
></span>
</a>
);
})}
</div>
</>
)}
</div>
);
}

@ -1,5 +1,5 @@
---
import type { FAQType } from '../../components/FAQs/FAQs.astro';
import type { FAQType } from '../../../components/FAQs/FAQs.astro';
export const faqs: FAQType[] = [
{

@ -0,0 +1,9 @@
export function getPercentage(portion: number, total: number): string {
if (total <= 0 || portion <= 0) {
return '0';
} else if (portion > total) {
return '100';
}
return ((portion / total) * 100).toFixed(2);
}

@ -1,5 +1,5 @@
import type { MarkdownFileType } from './file';
import type { BestPracticeFrontmatter } from './best-pratice';
import type { BestPracticeFrontmatter } from './best-practice';
// Generates URL from the topic file path e.g.
// -> /src/data/best-practices/frontend-performance/content/100-use-https-everywhere
@ -34,7 +34,7 @@ export async function getAllBestPracticeTopicFiles(): Promise<
'/src/data/best-practices/*/content/**/*.md',
{
eager: true,
}
},
);
const mapping: Record<string, BestPracticeTopicFileType> = {};

@ -32,3 +32,17 @@ export function getRelativeTimeString(date: string): string {
return relativeTime;
}
export function formatMonthDate(date: string): string {
return new Date(date).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
});
}
export function formatActivityDate(date: string): string {
return new Date(date).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
});
}

@ -29,7 +29,7 @@ export async function getAllLinkGroups(): Promise<LinkGroupFileType[]> {
'/src/data/link-groups/*.md',
{
eager: true,
}
},
);
return Object.values(linkGroups).map((linkGroupFile) => ({
@ -37,3 +37,14 @@ export async function getAllLinkGroups(): Promise<LinkGroupFileType[]> {
id: linkGroupPathToId(linkGroupFile.file),
}));
}
export async function getLinkGroupById(
groupId: string,
): Promise<LinkGroupFileType> {
const linkGroup = await import(`../data/link-groups/${groupId}.md`);
return {
...linkGroup,
id: linkGroupPathToId(linkGroup.file),
};
}

@ -118,6 +118,12 @@ export async function getAllQuestionGroups(): Promise<QuestionGroupType[]> {
.sort((a, b) => a.frontmatter.order - b.frontmatter.order);
}
export async function getQuestionGroupById(id: string) {
const questionGroups = await getAllQuestionGroups();
return questionGroups.find((group) => group.id === id);
}
export async function getQuestionGroupsByIds(
ids: string[],
): Promise<{ id: string; title: string; description: string }[]> {

@ -128,3 +128,11 @@ export async function getRoadmapsByIds(
return Promise.all(ids.map((id) => getRoadmapById(id)));
}
export async function getRoadmapFaqsById(roadmapId: string): Promise<string[]> {
const { faqs } = await import(
`../data/roadmaps/${roadmapId}/faqs.astro`
).catch(() => ({}));
return faqs || [];
}

@ -1,8 +1,8 @@
import type { MarkdownFileType } from './file';
import type { AuthorFileType } from './author.ts';
import { getAllAuthors } from './author.ts';
import type {GuideFileType} from "./guide.ts";
import {getAllGuides} from "./guide.ts";
import type { GuideFileType } from './guide.ts';
import { getAllGuides } from './guide.ts';
export interface VideoFrontmatter {
title: string;
@ -40,7 +40,7 @@ function videoPathToId(filePath: string): string {
}
export async function getVideosByAuthor(
authorId: string,
authorId: string,
): Promise<VideoFileType[]> {
const allVideos = await getAllVideos();
@ -73,3 +73,22 @@ export async function getAllVideos(): Promise<VideoFileType[]> {
new Date(a.frontmatter.date).valueOf(),
);
}
export async function getVideoById(id: string): Promise<VideoFileType> {
const videoFilesMap: Record<string, VideoFileType> =
import.meta.glob<VideoFileType>('../data/videos/*.md', {
eager: true,
});
const videoFile = Object.values(videoFilesMap).find((videoFile) => {
return videoPathToId(videoFile.file) === id;
});
if (!videoFile) {
throw new Error(`Video with ID ${id} not found`);
}
return {
...videoFile,
id: videoPathToId(videoFile.file),
};
}

@ -1,6 +1,4 @@
---
import RoadmapBanner from '../../components/RoadmapBanner.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import {
getRoadmapTopicFiles,
type RoadmapTopicFileType,

@ -1,7 +1,6 @@
---
import FAQs from '../../components/FAQs/FAQs.astro';
import FAQs, { type FAQType } from '../../components/FAQs/FAQs.astro';
import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro';
import MarkdownFile from '../../components/MarkdownFile.astro';
import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro';
import RoadmapHeader from '../../components/RoadmapHeader.astro';
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
@ -54,7 +53,7 @@ if (roadmapData.schema) {
}
if (roadmapFAQs.length) {
jsonLdSchema.push(generateFAQSchema(roadmapFAQs));
jsonLdSchema.push(generateFAQSchema(roadmapFAQs as unknown as FAQType[]));
}
const ogImageUrl =
@ -125,7 +124,7 @@ const ogImageUrl =
client:only='react'
/>
<FAQs faqs={roadmapFAQs} />
<FAQs faqs={roadmapFAQs as unknown as FAQType[]} />
<RelatedRoadmaps roadmap={roadmapData} />
</div>

@ -1,7 +1,7 @@
import type { APIRoute } from 'astro';
export async function getStaticPaths() {
const roadmapJsons = await import.meta.glob('/src/data/roadmaps/**/*.json', {
const roadmapJsons = import.meta.glob('/src/data/roadmaps/**/*.json', {
eager: true,
});

@ -1,6 +1,7 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import { UpdateProfileForm } from '../../components/UpdateProfile/UpdateProfileForm';
import { UpdatePublicProfileForm } from '../../components/UpdateProfile/UpdatePublicProfileForm';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
@ -11,5 +12,6 @@ import AccountLayout from '../../layouts/AccountLayout.astro';
>
<AccountSidebar activePageId='profile' activePageTitle='Profile'>
<UpdateProfileForm client:load />
<UpdatePublicProfileForm client:load />
</AccountSidebar>
</AccountLayout>

@ -2,7 +2,7 @@
import BaseLayout from '../../layouts/BaseLayout.astro';
import AstroIcon from '../../components/AstroIcon.astro';
import { getGuidesByAuthor } from '../../lib/guide';
import { getAllVideos, getVideosByAuthor } from '../../lib/video';
import { getVideosByAuthor } from '../../lib/video';
import GuideListItem from '../../components/GuideListItem.astro';
import { getAuthorById, getAuthorIds } from '../../lib/author';
import VideoListItem from '../../components/VideoListItem.astro';

@ -6,7 +6,10 @@ import { getGuideById } from '../../lib/guide';
import { getOpenGraphImageUrl } from '../../lib/open-graph';
const guideId = 'backend-languages';
const guide = await getGuideById('backend-languages');
const guide = await getGuideById(guideId).catch(() => null);
if (!guide) {
return Astro.redirect('/404');
}
const { frontmatter: guideData } = guide!;

@ -7,13 +7,13 @@ import { TopicDetail } from '../../../components/TopicDetail/TopicDetail';
import UpcomingForm from '../../../components/UpcomingForm.astro';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import { UserProgressModal } from '../../../components/UserProgress/UserProgressModal';
import {
type BestPracticeFileType,
type BestPracticeFrontmatter,
getAllBestPractices,
} from '../../../lib/best-pratice';
import { generateArticleSchema } from '../../../lib/jsonld-schema';
import { getOpenGraphImageUrl } from '../../../lib/open-graph';
import {
BestPracticeFileType,
BestPracticeFrontmatter,
getAllBestPractices,
} from '../../../lib/best-practice';
export async function getStaticPaths() {
const bestPractices = await getAllBestPractices();

@ -5,7 +5,7 @@ export async function getStaticPaths() {
'/src/data/best-practices/**/*.json',
{
eager: true,
}
},
);
return Object.keys(bestPracticeJsons).map((filePath) => {

@ -2,7 +2,7 @@
import GridItem from '../../components/GridItem.astro';
import SimplePageHeader from '../../components/SimplePageHeader.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getAllBestPractices } from '../../lib/best-pratice';
import { getAllBestPractices } from '../../lib/best-practice';
const bestPractices = await getAllBestPractices();
---

@ -4,7 +4,7 @@ import FeaturedGuides from '../components/FeaturedGuides.astro';
import FeaturedItems from '../components/FeaturedItems/FeaturedItems.astro';
import HeroSection from '../components/HeroSection/HeroSection.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { getAllBestPractices } from '../lib/best-pratice';
import { getAllBestPractices } from '../lib/best-practice';
import { getAllGuides } from '../lib/guide';
import { getRoadmapsByTag } from '../lib/roadmap';
import { getAllVideos } from '../lib/video';

@ -1,4 +1,4 @@
import { getAllBestPractices } from '../lib/best-pratice';
import { getAllBestPractices } from '../lib/best-practice';
import { getAllGuides } from '../lib/guide';
import { getRoadmapsByTag } from '../lib/roadmap';
import { getAllVideos } from '../lib/video';

@ -38,11 +38,11 @@ const { frontmatter } = questionGroup;
>
<div class='flex bg-gray-50 pb-14 pt-4 sm:pb-16 sm:pt-8'>
<div class='container !max-w-[740px]'>
<div class='mb-3 sm:mb-5 mt-2 text-left sm:text-center sm:mt-8'>
<div class='mb-3 mt-2 text-left sm:mb-5 sm:mt-8 sm:text-center'>
<div class='mb-2 md:mb-6'>
<a
href='/questions'
class='group rounded-md text-sm font-medium text-gray-400 hover:text-gray-800 transition-colors duration-200'
class='group rounded-md text-sm font-medium text-gray-400 transition-colors duration-200 hover:text-gray-800'
>
<span
class='inline-block transform transition-transform group-hover:translate-x-[-2px]'
@ -55,7 +55,7 @@ const { frontmatter } = questionGroup;
<h1 class='mb-1 text-2xl font-bold sm:mb-5 sm:text-5xl'>
{frontmatter.title}
</h1>
<p class='hidden sm:block text-xl text-gray-500'>
<p class='hidden text-xl text-gray-500 sm:block'>
{frontmatter.description}
</p>
</div>

@ -0,0 +1,26 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { CustomRoadmap } from '../../components/CustomRoadmap/CustomRoadmap';
import { SkeletonRoadmapHeader } from '../../components/CustomRoadmap/SkeletonRoadmapHeader';
import Loader from '../../components/Loader.astro';
import ProgressHelpPopup from '../../components/ProgressHelpPopup.astro';
export const prerender = false;
const { customRoadmapSlug } = Astro.params;
---
<BaseLayout title='Roadmaps'>
<ProgressHelpPopup />
<div>
<div class='flex min-h-[550px] flex-col'>
<div data-roadmap-loader class='flex w-full grow flex-col'>
<SkeletonRoadmapHeader />
<div class='flex grow items-center justify-center'>
<Loader />
</div>
</div>
<CustomRoadmap slug={customRoadmapSlug} client:only='react' />
</div>
</div>
</BaseLayout>

@ -0,0 +1,61 @@
---
import { FrownIcon } from 'lucide-react';
import { userApi } from '../../api/user';
import AccountLayout from '../../layouts/AccountLayout.astro';
import { UserPublicProfilePage } from '../../components/UserPublicProfile/UserPublicProfilePage';
import OpenSourceBanner from '../../components/OpenSourceBanner.astro';
import Footer from '../../components/Footer.astro';
export const prerender = false;
interface Params extends Record<string, string | undefined> {
username: string;
}
const { username } = Astro.params as Params;
if (!username) {
return Astro.redirect('/404');
}
const userClient = userApi(Astro as any);
const { response: userDetails, error } =
await userClient.getPublicProfile(username);
let errorMessage = '';
if (error || !userDetails) {
errorMessage = error?.message || 'User not found';
}
---
<AccountLayout title={userDetails?.name} errorMessage={errorMessage}>
{!errorMessage && <UserPublicProfilePage {...userDetails} client:load />}
{
errorMessage && (
<div class='container my-24 flex flex-col'>
<picture>
<source
srcset='https://fonts.gstatic.com/s/e/notoemoji/latest/1f61e/512.webp'
type='image/webp'
/>
<img
src='https://fonts.gstatic.com/s/e/notoemoji/latest/1f61e/512.gif'
alt='😞'
width='120'
height='120'
/>
</picture>
<h2 class='my-2 text-2xl font-bold sm:my-3 sm:text-4xl'>
Problem loading user!
</h2>
<p class='text-lg'>
<span class='rounded-md bg-red-600 px-2 py-1 text-white'>
{errorMessage}
</span>
</p>
</div>
)
}
<OpenSourceBanner />
<Footer />
</AccountLayout>

@ -0,0 +1,7 @@
import { execSync } from 'child_process';
export const prerender = false;
export async function GET() {
return new Response(JSON.stringify({}), {});
}

@ -0,0 +1,33 @@
import { execSync } from 'child_process';
export const prerender = false;
export async function GET() {
const commitHash = execSync('git rev-parse HEAD').toString().trim();
const commitDate = execSync('git log -1 --format=%cd').toString().trim();
const commitMessage = execSync('git log -1 --format=%B').toString().trim();
const prevCommitHash = execSync('git rev-parse HEAD~1').toString().trim();
const prevCommitDate = execSync('git log -1 --format=%cd HEAD~1')
.toString()
.trim();
const prevCommitMessage = execSync('git log -1 --format=%B HEAD~1')
.toString()
.trim();
return new Response(
JSON.stringify({
current: {
hash: commitHash,
date: commitDate,
message: commitMessage,
},
previous: {
hash: prevCommitHash,
date: prevCommitDate,
message: prevCommitMessage,
},
}),
{},
);
}

@ -1,7 +1,7 @@
---
import VideoHeader from '../../components/VideoHeader.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getAllVideos,VideoFileType } from '../../lib/video';
import { getAllVideos, VideoFileType } from '../../lib/video';
export interface Props {
video: VideoFileType;
@ -29,7 +29,7 @@ const { video } = Astro.props;
<div class='bg-gray-50 py-5 sm:py-10'>
<div
class='container prose prose-code:bg-transparent prose-h2:text-3xl prose-h2:mt-4 prose-h2:mb-2 prose-h3:mt-2 prose-img:mt-1'
class='container prose prose-h2:mb-2 prose-h2:mt-4 prose-h2:text-3xl prose-h3:mt-2 prose-code:bg-transparent prose-img:mt-1'
>
<video.Content />
</div>

Loading…
Cancel
Save