diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 029b077a3..4a605c7cb 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -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 } }' \ No newline at end of file diff --git a/.github/workflows/rsync-ssr.yml b/.github/workflows/rsync-ssr.yml deleted file mode 100644 index 3462d3084..000000000 --- a/.github/workflows/rsync-ssr.yml +++ /dev/null @@ -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 } }' \ No newline at end of file diff --git a/astro.config.mjs b/astro.config.mjs index b2dd80913..f4d152ab6 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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: { diff --git a/package.json b/package.json index 0ec606dec..401e083bb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 742114163..0452e828f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/generate-renderer.sh b/scripts/generate-renderer.sh index e8de75e92..faf9519e5 100644 --- a/scripts/generate-renderer.sh +++ b/scripts/generate-renderer.sh @@ -29,4 +29,4 @@ done # ignore the worktree changes for the editor directory -git update-index --assume-unchanged editor/readonly-editor.tsx \ No newline at end of file +git update-index --assume-unchanged editor/readonly-editor.tsx || true \ No newline at end of file diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 000000000..8917e95be --- /dev/null +++ b/src/api/api.ts @@ -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 }; + +type AppResponse = Record; + +export type FetchError = { + status: number; + message: string; +}; + +export type AppError = { + status: number; + message: string; + errors?: { message: string; location: string }[]; +}; + +export type ApiReturn = { + response?: ResponseType; + error?: ErrorType | FetchError; +}; + +export function api(context: APIContext) { + const token = context.cookies.get(TOKEN_COOKIE_NAME)?.value; + + async function apiCall( + url: string, + options?: HttpOptionsType, + ): Promise> { + 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( + url: string, + queryParams?: Record, + options?: HttpOptionsType, + ): Promise> { + const searchParams = new URLSearchParams(queryParams).toString(); + const queryUrl = searchParams ? `${url}?${searchParams}` : url; + + return apiCall(queryUrl, { + ...options, + method: 'GET', + }); + }, + post: async function apiPost< + ResponseType = AppResponse, + ErrorType = AppError, + >( + url: string, + body: Record, + options?: HttpOptionsType, + ): Promise> { + return apiCall(url, { + ...options, + method: 'POST', + body: JSON.stringify(body), + }); + }, + patch: async function apiPatch< + ResponseType = AppResponse, + ErrorType = AppError, + >( + url: string, + body: Record, + options?: HttpOptionsType, + ): Promise> { + return apiCall(url, { + ...options, + method: 'PATCH', + body: JSON.stringify(body), + }); + }, + put: async function apiPut< + ResponseType = AppResponse, + ErrorType = AppError, + >( + url: string, + body: Record, + options?: HttpOptionsType, + ): Promise> { + return apiCall(url, { + ...options, + method: 'PUT', + body: JSON.stringify(body), + }); + }, + delete: async function apiDelete< + ResponseType = AppResponse, + ErrorType = AppError, + >( + url: string, + options?: HttpOptionsType, + ): Promise> { + return apiCall(url, { + ...options, + method: 'DELETE', + }); + }, + }; +} diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 000000000..b10458dbf --- /dev/null +++ b/src/api/user.ts @@ -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; + 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; + 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( + `${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( + `${ + import.meta.env.PUBLIC_API_URL + }/v1-get-user-profile-roadmap/${username}`, + { + resourceId, + resourceType, + }, + ); + }, + }; +} diff --git a/src/components/AccountSidebar.astro b/src/components/AccountSidebar.astro index 1f6b7507f..9b609304e 100644 --- a/src/components/AccountSidebar.astro +++ b/src/components/AccountSidebar.astro @@ -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', diff --git a/src/components/Activity/ActivityPage.tsx b/src/components/Activity/ActivityPage.tsx index 354a0bdae..482fd27e5 100644 --- a/src/components/Activity/ActivityPage.tsx +++ b/src/components/Activity/ActivityPage.tsx @@ -14,6 +14,7 @@ type ProgressResponse = { done: number; total: number; isCustomResource: boolean; + roadmapSlug?: string; }; export type ActivityResponse = { diff --git a/src/components/Activity/ResourceProgress.tsx b/src/components/Activity/ResourceProgress.tsx index 3b037d06e..536528f98 100644 --- a/src/components/Activity/ResourceProgress.tsx +++ b/src/components/Activity/ResourceProgress.tsx @@ -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; diff --git a/src/components/CreateTeam/RoadmapSelector.tsx b/src/components/CreateTeam/RoadmapSelector.tsx index b3d160276..c7462361f 100644 --- a/src/components/CreateTeam/RoadmapSelector.tsx +++ b/src/components/CreateTeam/RoadmapSelector.tsx @@ -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) { )} ); - } + }, )} )} diff --git a/src/components/CreateVersion/CreateVersion.tsx b/src/components/CreateVersion/CreateVersion.tsx index 8fcf50554..075886182 100644 --- a/src/components/CreateVersion/CreateVersion.tsx +++ b/src/components/CreateVersion/CreateVersion.tsx @@ -82,7 +82,7 @@ export function CreateVersion(props: CreateVersionProps) { return (
diff --git a/src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx b/src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx index b7cf3ea05..00be8ac5d 100644 --- a/src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx +++ b/src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx @@ -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} diff --git a/src/components/CustomRoadmap/CustomRoadmap.tsx b/src/components/CustomRoadmap/CustomRoadmap.tsx index e8c16008c..d04af93b6 100644 --- a/src/components/CustomRoadmap/CustomRoadmap.tsx +++ b/src/components/CustomRoadmap/CustomRoadmap.tsx @@ -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); diff --git a/src/components/CustomRoadmap/PersonalRoadmapList.tsx b/src/components/CustomRoadmap/PersonalRoadmapList.tsx index 7e863abbb..c50b780de 100644 --- a/src/components/CustomRoadmap/PersonalRoadmapList.tsx +++ b/src/components/CustomRoadmap/PersonalRoadmapList.tsx @@ -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( - `${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 && ( Promise; setSelectedRoadmap: ( - roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null + roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null, ) => void; }; @@ -183,9 +184,9 @@ function CustomRoadmapItem(props: CustomRoadmapItemProps) { Edit diff --git a/src/components/CustomRoadmap/ResourceProgressStats.tsx b/src/components/CustomRoadmap/ResourceProgressStats.tsx index f9a4bfa43..9ebe21a6b 100644 --- a/src/components/CustomRoadmap/ResourceProgressStats.tsx +++ b/src/components/CustomRoadmap/ResourceProgressStats.tsx @@ -24,6 +24,7 @@ export function ResourceProgressStats(props: ResourceProgressStatsProps) { <> {isSharing && $canManageCurrentRoadmap && $currentRoadmap && (

setIsSharingWithOthers(false)} @@ -135,7 +137,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {

@@ -144,6 +146,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) { <> {isSharing && $currentRoadmap && ( diff --git a/src/components/HeroSection/FavoriteRoadmaps.tsx b/src/components/HeroSection/FavoriteRoadmaps.tsx index 73dced17b..a639be626 100644 --- a/src/components/HeroSection/FavoriteRoadmaps.tsx +++ b/src/components/HeroSection/FavoriteRoadmaps.tsx @@ -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( - `${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 diff --git a/src/components/HeroSection/HeroRoadmaps.tsx b/src/components/HeroSection/HeroRoadmaps.tsx index 8f92e4a76..02662102f 100644 --- a/src/components/HeroSection/HeroRoadmaps.tsx +++ b/src/components/HeroSection/HeroRoadmaps.tsx @@ -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} /> ); diff --git a/src/components/Navigation/AccountDropdownList.tsx b/src/components/Navigation/AccountDropdownList.tsx index 395b67339..fcace5119 100644 --- a/src/components/Navigation/AccountDropdownList.tsx +++ b/src/components/Navigation/AccountDropdownList.tsx @@ -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" > - Profile + Account
  • diff --git a/src/components/NavigationDropdown.tsx b/src/components/NavigationDropdown.tsx index ac05bf95c..4d7fb8faa 100644 --- a/src/components/NavigationDropdown.tsx +++ b/src/components/NavigationDropdown.tsx @@ -68,12 +68,13 @@ export function NavigationDropdown() { })} onClick={() => setIsOpen(true)} onMouseOver={() => setIsOpen(true)} + aria-label="Open Navigation Dropdown" >
    void; -}; +} & ButtonHTMLAttributes; export function SelectionButton(props: SelectionButtonProps) { - const { text, isDisabled, isSelected, onClick } = props; + const { + icon: Icon, + text, + isDisabled, + isSelected, + onClick, + className, + ...rest + } = props; return ( ); diff --git a/src/components/ShareOptions/ShareOptionsModal.tsx b/src/components/ShareOptions/ShareOptionsModal.tsx index 3c257a4d8..8962f98f2 100644 --- a/src/components/ShareOptions/ShareOptionsModal.tsx +++ b/src/components/ShareOptions/ShareOptionsModal.tsx @@ -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( - defaultSharedMemberIds + defaultSharedMemberIds, ); const [sharedFriendIds, setSharedFriendIds] = useState( - defaultSharedFriendIds + defaultSharedFriendIds, ); const [selectedTeamId, setSelectedTeamId] = useState(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" > 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} diff --git a/src/components/ShareOptions/ShareSuccess.tsx b/src/components/ShareOptions/ShareSuccess.tsx index de0c2bb39..1785bfbdb 100644 --- a/src/components/ShareOptions/ShareSuccess.tsx +++ b/src/components/ShareOptions/ShareSuccess.tsx @@ -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) {

    { - 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} />
    @@ -127,7 +131,7 @@ export function ShareSuccess(props: ShareSuccessProps) { diff --git a/src/components/UpdateProfile/UpdatePublicProfileForm.tsx b/src/components/UpdateProfile/UpdatePublicProfileForm.tsx new file mode 100644 index 000000000..18231dfe7 --- /dev/null +++ b/src/components/UpdateProfile/UpdatePublicProfileForm.tsx @@ -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('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('all'); + const [customRoadmapVisibility, setCustomRoadmapVisibility] = + useState('all'); + const [roadmaps, setRoadmaps] = useState([]); + const [customRoadmaps, setCustomRoadmaps] = useState([]); + + const [currentUsername, setCurrentUsername] = useState(''); + + const [github, setGithub] = useState(''); + const [twitter, setTwitter] = useState(''); + const [linkedin, setLinkedin] = useState(''); + const [website, setWebsite] = useState(''); + + const [profileRoadmaps, setProfileRoadmaps] = useState([]); + + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + 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( + `${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 ( +
    + {isCreatingRoadmap && ( + setIsCreatingRoadmap(false)} /> + )} + +
    +
    +

    + Personal Profile +

    + {publicProfileUrl && ( + + + Visit + + )} +
    + +
    +

    + Set up your public profile to showcase your learning progress. +

    + +
    +
    + + setHeadline((e.target as HTMLInputElement).value)} + required={profileVisibility === 'public'} + /> +
    + + + +
    +

    + Which roadmap progresses do you want to show on your profile? +

    +
    + { + setRoadmapVisibility('all'); + setRoadmaps([]); + }} + /> + { + setRoadmapVisibility('none'); + setRoadmaps([]); + }} + /> +
    + +

    + Or select the roadmaps you want to show +

    + {publicRoadmaps.length > 0 ? ( +
    + {publicRoadmaps.map((r) => ( + { + if (roadmapVisibility !== 'selected') { + setRoadmapVisibility('selected'); + } + + if (roadmaps.includes(r.id)) { + setRoadmaps(roadmaps.filter((id) => id !== r.id)); + } else { + setRoadmaps([...roadmaps, r.id]); + } + }} + /> + ))} +
    + ) : ( +

    + Update{' '} + + your progress on roadmaps + {' '} + to show your learning activity. +

    + )} +
    + +
    +

    + Pick your custom roadmaps to show on your profile +

    +
    + { + setCustomRoadmapVisibility('all'); + setCustomRoadmaps([]); + }} + /> + { + setCustomRoadmapVisibility('none'); + setCustomRoadmaps([]); + }} + /> +
    + +

    + Or select the custom roadmaps you want to show +

    + {publicCustomRoadmaps.length > 0 ? ( +
    + {publicCustomRoadmaps.map((r) => ( + { + if (customRoadmapVisibility !== 'selected') { + setCustomRoadmapVisibility('selected'); + } + + if (customRoadmaps.includes(r.id)) { + setCustomRoadmaps( + customRoadmaps.filter((id) => id !== r.id), + ); + } else { + setCustomRoadmaps([...customRoadmaps, r.id]); + } + }} + /> + ))} +
    + ) : ( +

    + You do not have any custom roadmaps.{' '} + + . +

    + )} +
    + +
    + + setGithub((e.target as HTMLInputElement).value)} + /> +
    +
    + + setTwitter((e.target as HTMLInputElement).value)} + /> +
    + +
    + + setLinkedin((e.target as HTMLInputElement).value)} + /> +
    + +
    + + setWebsite((e.target as HTMLInputElement).value)} + /> +
    + +
    +
    + setIsEmailVisible(e.target.checked)} + /> + +
    + +
    + setIsAvailableForHire(e.target.checked)} + /> + +
    +
    + + + +
    + ); +} diff --git a/src/components/UpdateProfile/VisibilityDropdown.tsx b/src/components/UpdateProfile/VisibilityDropdown.tsx new file mode 100644 index 000000000..6e3354b9f --- /dev/null +++ b/src/components/UpdateProfile/VisibilityDropdown.tsx @@ -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(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 ( +
    + + {isVisibilityDropdownOpen && ( +
    + + +
    + )} +
    + ); +} diff --git a/src/components/UserPublicProfile/PrivateProfileBanner.tsx b/src/components/UserPublicProfile/PrivateProfileBanner.tsx new file mode 100644 index 000000000..f47cdcc81 --- /dev/null +++ b/src/components/UserPublicProfile/PrivateProfileBanner.tsx @@ -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 ( +
    + + Your profile is private. Only you can see this page. +
    + ); + } + + return null; +} diff --git a/src/components/UserPublicProfile/UserProfileRoadmap.tsx b/src/components/UserPublicProfile/UserProfileRoadmap.tsx new file mode 100644 index 000000000..0d42ba79d --- /dev/null +++ b/src/components/UserPublicProfile/UserProfileRoadmap.tsx @@ -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 ( + <> + +
    + + +

    {title}

    +

    + Skills {name} has mastered on the {title?.toLowerCase()}. +

    +
    + +
    +

    + + {progressPercentage}% Done + + + + + {done.length} completed + + · + + {learning.length} in progress + + · + + {skipped.length} skipped + + · + + {topicCount} Total + + + + {totalMarked} of {topicCount} Done + +

    +
    + + + + ); +} diff --git a/src/components/UserPublicProfile/UserProfileRoadmapRenderer.tsx b/src/components/UserPublicProfile/UserProfileRoadmapRenderer.tsx new file mode 100644 index 000000000..aa6103f83 --- /dev/null +++ b/src/components/UserPublicProfile/UserProfileRoadmapRenderer.tsx @@ -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(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 ( +
    +
    + {isCustomResource ? ( + ) => { + 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" + /> + ) : ( +
    + )} + + {isLoading && ( +
    + +
    + )} +
    +
    + ); +} diff --git a/src/components/UserPublicProfile/UserPublicActivityHeatmap.tsx b/src/components/UserPublicProfile/UserPublicActivityHeatmap.tsx new file mode 100644 index 000000000..80be3cbaa --- /dev/null +++ b/src/components/UserPublicProfile/UserPublicActivityHeatmap.tsx @@ -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 ( +
    +
    +
    +

    Activity

    +

    + Progress updates over the past year +

    +
    + + Member since: {formatMonthDate(props.joinedAt)} + +
    + { + 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}`, + }; + }} + /> + + + +
    + + Number of topics marked as learning, or completed by day + +
    + Less + {legends.map((legend) => ( +
    +
    +
    + ))} + More + +
    +
    +
    + ); +} diff --git a/src/components/UserPublicProfile/UserPublicProfileHeader.tsx b/src/components/UserPublicProfile/UserPublicProfileHeader.tsx new file mode 100644 index 000000000..769e310af --- /dev/null +++ b/src/components/UserPublicProfile/UserPublicProfileHeader.tsx @@ -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 ( +
    + {name} + +
    + {isAvailableForHire && ( + + Available for hire + + )} +

    {name}

    +

    {headline}

    +
    + {links?.github && } + {links?.linkedin && ( + + )} + {links?.twitter && } + {links?.website && } + {isEmailVisible && } +
    +
    +
    + ); +} + +type UserLinkProps = { + href: string; + icon: typeof Github; +}; + +export function UserLink(props: UserLinkProps) { + const { href, icon: Icon } = props; + + return ( + + + + ); +} diff --git a/src/components/UserPublicProfile/UserPublicProfilePage.tsx b/src/components/UserPublicProfile/UserPublicProfilePage.tsx new file mode 100644 index 000000000..d7c8342bb --- /dev/null +++ b/src/components/UserPublicProfile/UserPublicProfilePage.tsx @@ -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 ( +
    +
    + + + + + + +
    +
    + ); +} diff --git a/src/components/UserPublicProfile/UserPublicProgressStats.tsx b/src/components/UserPublicProfile/UserPublicProgressStats.tsx new file mode 100644 index 000000000..9b8fc85f5 --- /dev/null +++ b/src/components/UserPublicProfile/UserPublicProgressStats.tsx @@ -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 ( + +

    + {title} +

    +
    +
    +
    + +
    + + {progressPercentage}% completed + + + Last updated {getRelativeTimeString(updatedAt)} + +
    +
    + ); +} diff --git a/src/components/UserPublicProfile/UserPublicProgresses.tsx b/src/components/UserPublicProfile/UserPublicProgresses.tsx new file mode 100644 index 000000000..ce5756270 --- /dev/null +++ b/src/components/UserPublicProfile/UserPublicProgresses.tsx @@ -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, + ); + + // + + return ( +
    + {customRoadmapVisibility !== 'none' && customRoadmaps?.length > 0 && ( +
    +

    + Roadmaps made by me +

    +
    + {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 ( + + {roadmap.title} + + ); + })} +
    +
    + )} + + {roadmapVisibility !== 'none' && roadmaps.length > 0 && ( + <> +

    + Skills I have mastered +

    +
    + {roadmaps.map((roadmap, counter) => { + const percentageDone = getPercentage( + roadmap.done + roadmap.skipped, + roadmap.total, + ); + + return ( + + {roadmap.title} + + {parseInt(percentageDone, 10)}% + + + + + ); + })} +
    + + )} +
    + ); +} diff --git a/src/data/roadmaps/frontend/faqs.astro b/src/data/roadmaps/frontend/faqs.astro index 54a5163fa..f8ddca8c1 100644 --- a/src/data/roadmaps/frontend/faqs.astro +++ b/src/data/roadmaps/frontend/faqs.astro @@ -1,5 +1,5 @@ --- -import type { FAQType } from '../../components/FAQs/FAQs.astro'; +import type { FAQType } from '../../../components/FAQs/FAQs.astro'; export const faqs: FAQType[] = [ { diff --git a/src/helper/number.ts b/src/helper/number.ts new file mode 100644 index 000000000..fc67f117a --- /dev/null +++ b/src/helper/number.ts @@ -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); +} diff --git a/src/lib/best-practice-topic.ts b/src/lib/best-practice-topic.ts index dc53fdcde..40a0debee 100644 --- a/src/lib/best-practice-topic.ts +++ b/src/lib/best-practice-topic.ts @@ -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 = {}; @@ -63,4 +63,4 @@ export async function getAllBestPracticeTopicFiles(): Promise< } return mapping; -} \ No newline at end of file +} diff --git a/src/lib/best-pratice.ts b/src/lib/best-practice.ts similarity index 100% rename from src/lib/best-pratice.ts rename to src/lib/best-practice.ts diff --git a/src/lib/date.ts b/src/lib/date.ts index 4df386da3..ecb6bd726 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -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', + }); +} diff --git a/src/lib/link-group.ts b/src/lib/link-group.ts index 960d8d1ea..9ed56cf6e 100644 --- a/src/lib/link-group.ts +++ b/src/lib/link-group.ts @@ -29,7 +29,7 @@ export async function getAllLinkGroups(): Promise { '/src/data/link-groups/*.md', { eager: true, - } + }, ); return Object.values(linkGroups).map((linkGroupFile) => ({ @@ -37,3 +37,14 @@ export async function getAllLinkGroups(): Promise { id: linkGroupPathToId(linkGroupFile.file), })); } + +export async function getLinkGroupById( + groupId: string, +): Promise { + const linkGroup = await import(`../data/link-groups/${groupId}.md`); + + return { + ...linkGroup, + id: linkGroupPathToId(linkGroup.file), + }; +} diff --git a/src/lib/question-group.ts b/src/lib/question-group.ts index e50eee1f6..a41e866e4 100644 --- a/src/lib/question-group.ts +++ b/src/lib/question-group.ts @@ -118,6 +118,12 @@ export async function getAllQuestionGroups(): Promise { .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 }[]> { diff --git a/src/lib/roadmap.ts b/src/lib/roadmap.ts index cb4983405..b3303c606 100644 --- a/src/lib/roadmap.ts +++ b/src/lib/roadmap.ts @@ -128,3 +128,11 @@ export async function getRoadmapsByIds( return Promise.all(ids.map((id) => getRoadmapById(id))); } + +export async function getRoadmapFaqsById(roadmapId: string): Promise { + const { faqs } = await import( + `../data/roadmaps/${roadmapId}/faqs.astro` + ).catch(() => ({})); + + return faqs || []; +} diff --git a/src/lib/video.ts b/src/lib/video.ts index 09629cf21..d34e27ab4 100644 --- a/src/lib/video.ts +++ b/src/lib/video.ts @@ -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 { const allVideos = await getAllVideos(); @@ -73,3 +73,22 @@ export async function getAllVideos(): Promise { new Date(a.frontmatter.date).valueOf(), ); } + +export async function getVideoById(id: string): Promise { + const videoFilesMap: Record = + import.meta.glob('../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), + }; +} diff --git a/src/pages/[roadmapId]/[...topicId].astro b/src/pages/[roadmapId]/[...topicId].astro index ce1b14977..aec201c85 100644 --- a/src/pages/[roadmapId]/[...topicId].astro +++ b/src/pages/[roadmapId]/[...topicId].astro @@ -1,6 +1,4 @@ --- -import RoadmapBanner from '../../components/RoadmapBanner.astro'; -import BaseLayout from '../../layouts/BaseLayout.astro'; import { getRoadmapTopicFiles, type RoadmapTopicFileType, @@ -36,4 +34,4 @@ const gitHubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste
    - \ No newline at end of file + diff --git a/src/pages/[roadmapId]/index.astro b/src/pages/[roadmapId]/index.astro index 55cf232b8..898c6d161 100644 --- a/src/pages/[roadmapId]/index.astro +++ b/src/pages/[roadmapId]/index.astro @@ -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' /> - +
    diff --git a/src/pages/[roadmapId]/index.json.ts b/src/pages/[roadmapId]/index.json.ts index db772aacd..7d4b6c0f3 100644 --- a/src/pages/[roadmapId]/index.json.ts +++ b/src/pages/[roadmapId]/index.json.ts @@ -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, }); diff --git a/src/pages/account/update-profile.astro b/src/pages/account/update-profile.astro index 3ad19b0dd..2db248e60 100644 --- a/src/pages/account/update-profile.astro +++ b/src/pages/account/update-profile.astro @@ -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'; > + diff --git a/src/pages/authors/[authorId].astro b/src/pages/authors/[authorId].astro index 1731ec9da..1d0cf6138 100644 --- a/src/pages/authors/[authorId].astro +++ b/src/pages/authors/[authorId].astro @@ -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'; diff --git a/src/pages/backend/languages.astro b/src/pages/backend/languages.astro index cfb5edc2c..11746690f 100644 --- a/src/pages/backend/languages.astro +++ b/src/pages/backend/languages.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!; diff --git a/src/pages/best-practices/[bestPracticeId]/index.astro b/src/pages/best-practices/[bestPracticeId]/index.astro index fa7dce388..54a1fd744 100644 --- a/src/pages/best-practices/[bestPracticeId]/index.astro +++ b/src/pages/best-practices/[bestPracticeId]/index.astro @@ -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(); diff --git a/src/pages/best-practices/[bestPracticeId]/index.json.ts b/src/pages/best-practices/[bestPracticeId]/index.json.ts index 4a7c7b06c..00400ce13 100644 --- a/src/pages/best-practices/[bestPracticeId]/index.json.ts +++ b/src/pages/best-practices/[bestPracticeId]/index.json.ts @@ -5,7 +5,7 @@ export async function getStaticPaths() { '/src/data/best-practices/**/*.json', { eager: true, - } + }, ); return Object.keys(bestPracticeJsons).map((filePath) => { diff --git a/src/pages/best-practices/index.astro b/src/pages/best-practices/index.astro index 52b7433d4..1e92f2fe2 100644 --- a/src/pages/best-practices/index.astro +++ b/src/pages/best-practices/index.astro @@ -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(); --- diff --git a/src/pages/index.astro b/src/pages/index.astro index 0013b5209..524822985 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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'; diff --git a/src/pages/pages.json.ts b/src/pages/pages.json.ts index 1fc6ebd7b..90288d7e3 100644 --- a/src/pages/pages.json.ts +++ b/src/pages/pages.json.ts @@ -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'; diff --git a/src/pages/questions/[questionGroupId].astro b/src/pages/questions/[questionGroupId].astro index 6091fceb8..1f72d1a46 100644 --- a/src/pages/questions/[questionGroupId].astro +++ b/src/pages/questions/[questionGroupId].astro @@ -38,11 +38,11 @@ const { frontmatter } = questionGroup; >
    -
    +
    diff --git a/src/pages/r/[customRoadmapSlug].astro b/src/pages/r/[customRoadmapSlug].astro new file mode 100644 index 000000000..e72b34c9f --- /dev/null +++ b/src/pages/r/[customRoadmapSlug].astro @@ -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; +--- + + + +
    +
    +
    + +
    + +
    +
    + +
    +
    +
    diff --git a/src/pages/u/[username].astro b/src/pages/u/[username].astro new file mode 100644 index 000000000..ef0a83d03 --- /dev/null +++ b/src/pages/u/[username].astro @@ -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 { + 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'; +} +--- + + + {!errorMessage && } + { + errorMessage && ( +
    + + + 😞 + +

    + Problem loading user! +

    +

    + + {errorMessage} + +

    +
    + ) + } + + +