Compare commits

..

2 Commits

Author SHA1 Message Date
Arik Chakma
52018e7030 fix: add layout in explore page 2024-04-03 22:17:12 +06:00
Arik Chakma
95d70c521a feat: add footer for ai generated roadmaps 2024-04-03 22:12:27 +06:00
104 changed files with 558 additions and 3512 deletions

View File

@@ -1,74 +1,41 @@
name: Deploy to EC2
name: App Deployment
on:
workflow_dispatch: # allow manual run
push:
branches:
- master
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
jobs:
deploy:
build:
runs-on: ubuntu-latest
steps:
- 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 } }'
- 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

View File

@@ -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,11 +41,9 @@ export default defineConfig({
],
],
},
output: 'hybrid',
adapter: node({
mode: 'standalone',
}),
trailingSlash: 'never',
build: {
format: 'file',
},
integrations: [
tailwind({
config: {

View File

@@ -23,7 +23,6 @@
"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",
@@ -35,7 +34,6 @@
"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",
@@ -50,10 +48,8 @@
"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",
@@ -73,7 +69,6 @@
"@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",

359
pnpm-lock.yaml generated
View File

@@ -5,12 +5,9 @@ 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.58)(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.59)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3)
'@astrojs/sitemap':
specifier: ^3.0.5
version: 3.0.5
@@ -25,10 +22,10 @@ dependencies:
version: 0.7.1(nanostores@0.9.5)(react@18.2.0)
'@resvg/resvg-js':
specifier: ^2.6.0
version: 2.6.2
version: 2.6.0
'@types/react':
specifier: ^18.2.56
version: 18.2.58
version: 18.2.59
'@types/react-dom':
specifier: ^18.2.19
version: 18.2.19
@@ -41,9 +38,6 @@ 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
@@ -86,21 +80,15 @@ 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.58)(react-dom@18.2.0)(react@18.2.0)
version: 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
rehype-external-links:
specifier: ^3.0.0
version: 3.0.0
@@ -133,7 +121,7 @@ dependencies:
version: 11.0.4
zustand:
specifier: ^4.5.1
version: 4.5.1(@types/react@18.2.58)(react@18.2.0)
version: 4.5.1(@types/react@18.2.59)(react@18.2.0)
devDependencies:
'@playwright/test':
@@ -151,9 +139,6 @@ 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
@@ -226,18 +211,6 @@ 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'}
@@ -245,7 +218,7 @@ packages:
prismjs: 1.29.0
dev: false
/@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):
/@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):
resolution: {integrity: sha512-uGRIwKMAn7tva2vxXMyoVIGxWFr0rjZ8ZWIlkTG/vIpnAjD2nM8Cz6B8j7yzj176jvl6gZ6xTbTVPm09aeK0Yw==}
engines: {node: '>=18.14.1'}
peerDependencies:
@@ -254,7 +227,7 @@ packages:
react: ^17.0.2 || ^18.0.0
react-dom: ^17.0.2 || ^18.0.0
dependencies:
'@types/react': 18.2.58
'@types/react': 18.2.59
'@types/react-dom': 18.2.19
'@vitejs/plugin-react': 4.2.1(vite@5.1.3)
react: 18.2.0
@@ -784,23 +757,6 @@ 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
@@ -1179,39 +1135,39 @@ packages:
config-chain: 1.1.13
dev: false
/@reactflow/background@11.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
/@reactflow/background@11.3.9(@types/react@18.2.59)(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.58)(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)
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.58)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/controls@11.2.9(@types/react@18.2.58)(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):
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.58)(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)
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.58)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/core@11.10.4(@types/react@18.2.58)(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):
resolution: {integrity: sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==}
peerDependencies:
react: '>=17'
@@ -1227,19 +1183,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.58)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/minimap@11.7.9(@types/react@18.2.58)(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):
resolution: {integrity: sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
dependencies:
'@reactflow/core': 11.10.4(@types/react@18.2.58)(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)
'@types/d3-selection': 3.0.10
'@types/d3-zoom': 3.0.8
classcat: 5.0.4
@@ -1247,48 +1203,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.58)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/node-resizer@2.2.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.59)(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.58)(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)
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.58)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@reactflow/node-toolbar@1.3.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.59)(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.58)(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)
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.58)(react@18.2.0)
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
dev: false
/@resvg/resvg-js-android-arm-eabi@2.6.2:
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
/@resvg/resvg-js-android-arm-eabi@2.6.0:
resolution: {integrity: sha512-lJnZ/2P5aMocrFMW7HWhVne5gH82I8xH6zsfH75MYr4+/JOaVcGCTEQ06XFohGMdYRP3v05SSPLPvTM/RHjxfA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
@@ -1296,8 +1252,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-android-arm64@2.6.2:
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
/@resvg/resvg-js-android-arm64@2.6.0:
resolution: {integrity: sha512-N527f529bjMwYWShZYfBD60dXA4Fux+D695QsHQ93BDYZSHUoOh1CUGUyICevnTxs7VgEl98XpArmUWBZQVMfQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
@@ -1305,8 +1261,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-darwin-arm64@2.6.2:
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
/@resvg/resvg-js-darwin-arm64@2.6.0:
resolution: {integrity: sha512-MabUKLVayEwlPo0mIqAmMt+qESN8LltCvv5+GLgVga1avpUrkxj/fkU1TKm8kQegutUjbP/B0QuMuUr0uhF8ew==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@@ -1314,8 +1270,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-darwin-x64@2.6.2:
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
/@resvg/resvg-js-darwin-x64@2.6.0:
resolution: {integrity: sha512-zrFetdnSw/suXjmyxSjfDV7i61hahv6DDG6kM7BYN2yJ3Es5+BZtqYZTcIWogPJedYKmzN1YTMWGd/3f0ubFiA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@@ -1323,8 +1279,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-linux-arm-gnueabihf@2.6.2:
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
/@resvg/resvg-js-linux-arm-gnueabihf@2.6.0:
resolution: {integrity: sha512-sH4gxXt7v7dGwjGyzLwn7SFGvwZG6DQqLaZ11MmzbCwd9Zosy1TnmrMJfn6TJ7RHezmQMgBPi18bl55FZ1AT4A==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
@@ -1332,8 +1288,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-linux-arm64-gnu@2.6.2:
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
/@resvg/resvg-js-linux-arm64-gnu@2.6.0:
resolution: {integrity: sha512-fCyMncqCJtrlANADIduYF4IfnWQ295UKib7DAxFXQhBsM9PLDTpizr0qemZcCNadcwSVHnAIzL4tliZhCM8P6A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -1341,8 +1297,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-linux-arm64-musl@2.6.2:
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
/@resvg/resvg-js-linux-arm64-musl@2.6.0:
resolution: {integrity: sha512-ouLjTgBQHQyxLht4FdMPTvuY8xzJigM9EM2Tlu0llWkN1mKyTQrvYWi6TA6XnKdzDJHy7ZLpWpjZi7F5+Pg+Vg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -1350,8 +1306,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-linux-x64-gnu@2.6.2:
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
/@resvg/resvg-js-linux-x64-gnu@2.6.0:
resolution: {integrity: sha512-n3zC8DWsvxC1AwxpKFclIPapDFibs5XdIRoV/mcIlxlh0vseW1F49b97F33BtJQRmlntsqqN6GMMqx8byB7B+Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -1359,8 +1315,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-linux-x64-musl@2.6.2:
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
/@resvg/resvg-js-linux-x64-musl@2.6.0:
resolution: {integrity: sha512-n4tasK1HOlAxdTEROgYA1aCfsEKk0UOFDNd/AQTTZlTmCbHKXPq+O8npaaKlwXquxlVK8vrkcWbksbiGqbCAcw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -1368,8 +1324,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-win32-arm64-msvc@2.6.2:
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
/@resvg/resvg-js-win32-arm64-msvc@2.6.0:
resolution: {integrity: sha512-X2+EoBJFwDI5LDVb51Sk7ldnVLitMGr9WwU/i21i3fAeAXZb3hM16k67DeTy16OYkT2dk/RfU1tP1wG+rWbz2Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@@ -1377,8 +1333,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-win32-ia32-msvc@2.6.2:
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
/@resvg/resvg-js-win32-ia32-msvc@2.6.0:
resolution: {integrity: sha512-L7oevWjQoUgK5W1fCKn0euSVemhDXVhrjtwqpc7MwBKKimYeiOshO1Li1pa8bBt5PESahenhWgdB6lav9O0fEg==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
@@ -1386,8 +1342,8 @@ packages:
dev: false
optional: true
/@resvg/resvg-js-win32-x64-msvc@2.6.2:
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
/@resvg/resvg-js-win32-x64-msvc@2.6.0:
resolution: {integrity: sha512-8lJlghb+Unki5AyKgsnFbRJwkEj9r1NpwyuBG8yEJiG1W9eEGl03R3I7bsVa3haof/3J1NlWf0rzSa1G++A2iw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1395,22 +1351,22 @@ packages:
dev: false
optional: true
/@resvg/resvg-js@2.6.2:
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
/@resvg/resvg-js@2.6.0:
resolution: {integrity: sha512-Tf3YpbBKcQn991KKcw/vg7vZf98v01seSv6CVxZBbRkL/xyjnoYB6KgrFL6zskT1A4dWC/vg77KyNOW+ePaNlA==}
engines: {node: '>= 10'}
optionalDependencies:
'@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
'@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
dev: false
/@rollup/rollup-android-arm-eabi@4.9.6:
@@ -1911,25 +1867,21 @@ packages:
/@types/prop-types@15.7.11:
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
/@types/react-calendar-heatmap@1.6.7:
resolution: {integrity: sha512-xWBS9iOvw+aCidPk8QwCH69OCO7jnj6/9TjooqGQ9W+rA5m1aw36GjQMlSYKAg86otDeg9dzA+hSAIcvw/y9Rg==}
dependencies:
'@types/react': 18.2.58
dev: true
dev: false
/@types/react-dom@18.2.19:
resolution: {integrity: sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==}
dependencies:
'@types/react': 18.2.58
'@types/react': 18.2.59
dev: false
/@types/react@18.2.58:
resolution: {integrity: sha512-TaGvMNhxvG2Q0K0aYxiKfNDS5m5ZsoIBBbtfUorxdH4NGSXIlYvZxLJI+9Dd3KjeB3780bciLyAb7ylO8pLhPw==}
/@types/react@18.2.59:
resolution: {integrity: sha512-DE+F6BYEC8VtajY85Qr7mmhTd/79rJKIHCg99MU9SWPB4xvLb6D1za2vYflgZfmPqQVEr6UqJTnLXEwzpVPuOg==}
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==}
@@ -1939,6 +1891,7 @@ 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==}
@@ -2512,10 +2465,6 @@ 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'}
@@ -2580,6 +2529,7 @@ 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
@@ -2759,6 +2709,7 @@ 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==}
@@ -2833,21 +2784,6 @@ 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'}
@@ -2898,21 +2834,11 @@ 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'}
@@ -3023,10 +2949,6 @@ 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
@@ -3045,11 +2967,6 @@ 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
@@ -3149,11 +3066,6 @@ 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'}
@@ -3335,11 +3247,6 @@ 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
@@ -3739,17 +3646,6 @@ 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'}
@@ -3879,6 +3775,7 @@ packages:
/is-arrayish@0.3.2:
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
requiresBuild: true
dev: false
/is-binary-path@2.1.0:
@@ -4589,10 +4486,6 @@ 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
@@ -4867,12 +4760,6 @@ 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'}
@@ -5014,10 +4901,6 @@ 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
@@ -5302,13 +5185,6 @@ 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:
@@ -5822,14 +5698,6 @@ 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
@@ -5879,11 +5747,6 @@ 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:
@@ -5905,16 +5768,6 @@ 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'}
@@ -5935,27 +5788,11 @@ 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'}
@@ -5963,18 +5800,18 @@ packages:
loose-envify: 1.4.0
dev: false
/reactflow@11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
/reactflow@11.10.4(@types/react@18.2.59)(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.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)
'@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)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
@@ -6349,27 +6186,6 @@ 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
@@ -6378,10 +6194,6 @@ 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'}
@@ -6488,6 +6300,7 @@ 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
@@ -6608,11 +6421,6 @@ 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}
@@ -6898,11 +6706,6 @@ 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
@@ -7425,7 +7228,7 @@ packages:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
dev: false
/zustand@4.5.1(@types/react@18.2.58)(react@18.2.0):
/zustand@4.5.1(@types/react@18.2.59)(react@18.2.0):
resolution: {integrity: sha512-XlauQmH64xXSC1qGYNv00ODaQ3B+tNPoy22jv2diYiP4eoDKr9LA+Bh5Bc3gplTrFdb6JVI+N4kc1DZ/tbtfPg==}
engines: {node: '>=12.7.0'}
peerDependencies:
@@ -7440,7 +7243,7 @@ packages:
react:
optional: true
dependencies:
'@types/react': 18.2.58
'@types/react': 18.2.59
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false

View File

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

View File

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

View File

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

View File

@@ -23,16 +23,6 @@ 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',
@@ -47,7 +37,7 @@ const sidebarLinks = [
href: '/account/roadmaps',
title: 'Roadmaps',
id: 'roadmaps',
isNew: false,
isNew: true,
icon: {
glyph: 'users',
classes: 'h-4 w-4',
@@ -64,6 +54,16 @@ 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',

View File

@@ -21,7 +21,7 @@ function ActivityCounter(props: ActivityCounterType) {
const { text, count } = props;
return (
<div className="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-[1.62rem] items-center gap-2 sm:gap-0 justify-end">
<div className="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
<h2 className="text-base sm:text-5xl font-bold">
{count}
</h2>

View File

@@ -4,7 +4,6 @@ import { ActivityCounters } from './ActivityCounters';
import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
import { ActivityStream, type UserStreamActivity } from './ActivityStream';
type ProgressResponse = {
updatedAt: string;
@@ -15,7 +14,6 @@ type ProgressResponse = {
done: number;
total: number;
isCustomResource: boolean;
roadmapSlug?: string;
};
export type ActivityResponse = {
@@ -46,7 +44,6 @@ export type ActivityResponse = {
resourceTitle?: string;
};
}[];
activities: UserStreamActivity[];
};
export function ActivityPage() {
@@ -98,13 +95,8 @@ export function ActivityPage() {
return updatedAtB.getTime() - updatedAtA.getTime();
})
.filter(
(bestPractice) => bestPractice.learning > 0 || bestPractice.done > 0,
);
.filter((bestPractice) => bestPractice.learning > 0 || bestPractice.done > 0);
const hasProgress =
learningRoadmapsToShow.length !== 0 ||
learningBestPracticesToShow.length !== 0;
return (
<>
@@ -114,17 +106,16 @@ export function ActivityPage() {
streak={activity?.streak || { count: 0 }}
/>
<div className="mx-0 px-0 py-5 pb-0 md:-mx-10 md:px-8 md:py-8 md:pb-0">
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
{learningRoadmapsToShow.length === 0 &&
learningBestPracticesToShow.length === 0 && <EmptyActivity />}
{(learningRoadmapsToShow.length > 0 ||
learningBestPracticesToShow.length > 0) && (
{(learningRoadmapsToShow.length > 0 || learningBestPracticesToShow.length > 0) && (
<>
<h2 className="mb-3 text-xs uppercase text-gray-400">
Continue Following
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
<div className="flex flex-col gap-3">
{learningRoadmaps
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
@@ -200,10 +191,6 @@ export function ActivityPage() {
</>
)}
</div>
{hasProgress && (
<ActivityStream activities={activity?.activities || []} />
)}
</>
);
}

View File

@@ -1,163 +0,0 @@
import { useMemo, useState } from 'react';
import { getRelativeTimeString } from '../../lib/date';
import type { ResourceType } from '../../lib/resource-progress';
import { EmptyStream } from './EmptyStream';
import { ActivityTopicsModal } from './ActivityTopicsModal.tsx';
import {Book, BookOpen, ChevronsDown, ChevronsDownUp, ChevronsUp, ChevronsUpDown} from 'lucide-react';
export const allowedActivityActionType = [
'in_progress',
'done',
'answered',
] as const;
export type AllowedActivityActionType =
(typeof allowedActivityActionType)[number];
export type UserStreamActivity = {
_id?: string;
resourceType: ResourceType | 'question';
resourceId: string;
resourceTitle: string;
resourceSlug?: string;
isCustomResource?: boolean;
actionType: AllowedActivityActionType;
topicIds?: string[];
createdAt: Date;
updatedAt: Date;
};
type ActivityStreamProps = {
activities: UserStreamActivity[];
};
export function ActivityStream(props: ActivityStreamProps) {
const { activities } = props;
const [showAll, setShowAll] = useState(false);
const [selectedActivity, setSelectedActivity] =
useState<UserStreamActivity | null>(null);
const sortedActivities = activities
.filter((activity) => activity?.topicIds && activity.topicIds.length > 0)
.sort((a, b) => {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
})
.slice(0, showAll ? activities.length : 10);
return (
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
<h2 className="mb-3 text-xs uppercase text-gray-400">
Learning Activity
</h2>
{selectedActivity && (
<ActivityTopicsModal
onClose={() => setSelectedActivity(null)}
activityId={selectedActivity._id!}
resourceId={selectedActivity.resourceId}
resourceType={selectedActivity.resourceType}
isCustomResource={selectedActivity.isCustomResource}
topicIds={selectedActivity.topicIds || []}
topicCount={selectedActivity.topicIds?.length || 0}
actionType={selectedActivity.actionType}
/>
)}
{activities.length > 0 ? (
<ul className="divide-y divide-gray-100">
{sortedActivities.map((activity) => {
const {
_id,
resourceType,
resourceId,
resourceTitle,
actionType,
updatedAt,
topicIds,
isCustomResource,
} = activity;
const resourceUrl =
resourceType === 'question'
? `/questions/${resourceId}`
: resourceType === 'best-practice'
? `/best-practices/${resourceId}`
: isCustomResource && resourceType === 'roadmap'
? `/r/${resourceId}`
: `/${resourceId}`;
const resourceLinkComponent = (
<a
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
target="_blank"
href={resourceUrl}
>
{resourceTitle}
</a>
);
const topicCount = topicIds?.length || 0;
const timeAgo = (
<span className="ml-1 text-xs text-gray-400">
{getRelativeTimeString(new Date(updatedAt).toISOString())}
</span>
);
return (
<li key={_id} className="py-2 text-sm text-gray-600">
{actionType === 'in_progress' && (
<>
Started{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => setSelectedActivity(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
in {resourceLinkComponent} {timeAgo}
</>
)}
{actionType === 'done' && (
<>
Completed{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => setSelectedActivity(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
in {resourceLinkComponent} {timeAgo}
</>
)}
{actionType === 'answered' && (
<>
Answered {topicCount} question{topicCount > 1 ? 's' : ''} in{' '}
{resourceLinkComponent} {timeAgo}
</>
)}
</li>
);
})}
</ul>
) : (
<EmptyStream />
)}
{activities.length > 10 && (
<button
className="mt-3 gap-2 flex items-center rounded-md border border-black pl-1.5 pr-2 py-1 text-xs uppercase tracking-wide text-black transition-colors hover:border-black hover:bg-black hover:text-white"
onClick={() => setShowAll(!showAll)}
>
{showAll ? <>
<ChevronsUp size={14} />
Show less
</> : <>
<ChevronsDown size={14} />
Show more
</>}
</button>
)}
</div>
);
}

View File

@@ -1,136 +0,0 @@
import { useEffect, useState } from 'react';
import type { ResourceType } from '../../lib/resource-progress';
import type { AllowedActivityActionType } from './ActivityStream';
import { httpPost } from '../../lib/http';
import { Modal } from '../Modal.tsx';
import { ModalLoader } from '../UserProgress/ModalLoader.tsx';
import { ArrowUpRight, BookOpen, Check } from 'lucide-react';
type ActivityTopicDetailsProps = {
activityId: string;
resourceId: string;
resourceType: ResourceType | 'question';
isCustomResource?: boolean;
topicIds: string[];
topicCount: number;
actionType: AllowedActivityActionType;
onClose: () => void;
};
export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
const {
resourceId,
resourceType,
isCustomResource,
topicIds = [],
topicCount,
actionType,
onClose,
} = props;
const [isLoading, setIsLoading] = useState(true);
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const loadTopicTitles = async () => {
setIsLoading(true);
setError(null);
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`,
{
resourceId,
resourceType,
isCustomResource,
topicIds,
},
);
if (error || !response) {
setError(error?.message || 'Failed to load topic titles');
setIsLoading(false);
return;
}
setTopicTitles(response);
setIsLoading(false);
};
useEffect(() => {
loadTopicTitles().finally(() => {
setIsLoading(false);
});
}, []);
if (isLoading || error) {
return (
<ModalLoader
error={error!}
text={'Loading topics..'}
isLoading={isLoading}
/>
);
}
let pageUrl = '';
if (resourceType === 'roadmap') {
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`;
} else if (resourceType === 'best-practice') {
pageUrl = `/best-practices/${resourceId}`;
} else {
pageUrl = `/questions/${resourceId}`;
}
return (
<Modal
onClose={() => {
onClose();
setError(null);
setIsLoading(false);
}}
>
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}>
<span className="mb-2 flex items-center justify-between text-lg font-semibold capitalize">
<span className="flex items-center gap-2">
{actionType.replace('_', ' ')}
</span>
<a
href={pageUrl}
target="_blank"
className="flex items-center gap-1 rounded-md border border-transparent py-0.5 pl-2 pr-1 text-sm font-normal text-gray-400 transition-colors hover:border-black hover:bg-black hover:text-white"
>
Visit Page{' '}
<ArrowUpRight
size={16}
strokeWidth={2}
className="relative top-px"
/>
</a>
</span>
<ul className="flex flex-col gap-1">
{topicIds.map((topicId) => {
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
const ActivityIcon =
actionType === 'done'
? Check
: actionType === 'in_progress'
? BookOpen
: Check;
return (
<li key={topicId} className="flex items-start gap-2">
<ActivityIcon
strokeWidth={3}
className="relative top-[4px] text-green-500"
size={16}
/>
{topicTitle}
</li>
);
})}
</ul>
</div>
</Modal>
);
}

View File

@@ -1,31 +0,0 @@
import { List } from 'lucide-react';
export function EmptyStream() {
return (
<div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center">
<List className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
<h2 className="text-lg font-bold sm:text-xl">No Activities</h2>
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
Activities will appear here as you start tracking your&nbsp;
<a href="/roadmaps" className="mt-4 text-blue-500 hover:underline">
Roadmaps
</a>
,&nbsp;
<a
href="/best-practices"
className="mt-4 text-blue-500 hover:underline"
>
Best Practices
</a>
&nbsp;or&nbsp;
<a href="/questions" className="mt-4 text-blue-500 hover:underline">
Questions
</a>
&nbsp;progress.
</p>
</div>
</div>
);
}

View File

@@ -1,6 +1,9 @@
import { httpPost } from '../../lib/http';
import { getRelativeTimeString } from '../../lib/date';
import { useToast } from '../../hooks/use-toast';
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
import { useState } from 'react';
import { getUser } from '../../lib/jwt';
import { getPercentage } from '../../helper/number';
import { ResourceProgressActions } from './ResourceProgressActions';
type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice';
@@ -14,11 +17,13 @@ type ResourceProgressType = {
onCleared?: () => void;
showClearButton?: boolean;
isCustomResource: boolean;
roadmapSlug?: string;
};
export function ResourceProgress(props: ResourceProgressType) {
const { showClearButton = true, isCustomResource } = props;
const toast = useToast();
const [isClearing, setIsClearing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const userId = getUser()?.id;
@@ -32,50 +37,134 @@ export function ResourceProgress(props: ResourceProgressType) {
doneCount,
skippedCount,
onCleared,
roadmapSlug,
} = props;
async function clearProgress() {
setIsClearing(true);
const { error, response } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`,
{
resourceId,
resourceType,
}
);
if (error || !response) {
toast.error('Error clearing progress. Please try again.');
console.error(error);
setIsClearing(false);
return;
}
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`);
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`);
setIsClearing(false);
setIsConfirming(false);
if (onCleared) {
onCleared();
}
}
let url =
resourceType === 'roadmap'
? `/${resourceId}`
: `/best-practices/${resourceId}`;
if (isCustomResource) {
url = `/r/${roadmapSlug}`;
url = `/r?id=${resourceId}`;
}
const totalMarked = doneCount + skippedCount;
const progressPercentage = getPercentage(totalMarked, totalCount);
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
return (
<div className="relative">
<div>
<a
target="_blank"
href={url}
className="group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 pr-7 text-left text-sm transition-all hover:border-gray-400"
className="group relative flex cursor-pointer items-center rounded-t-md border p-3 text-gray-600 hover:border-gray-300 hover:text-black"
>
<span className="flex-grow truncate">{title}</span>
<span className="text-xs text-gray-400">
{parseInt(progressPercentage, 10)}%
</span>
<span
className="absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 transition-colors group-hover:bg-black/10"
className={`absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10`}
style={{
width: `${progressPercentage}%`,
}}
></span>
<span className="relative flex-1 cursor-pointer truncate">
{title}
</span>
<span className="ml-1 cursor-pointer text-sm text-gray-400">
{getRelativeTimeString(updatedAt)}
</span>
</a>
<div className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
<span className="hidden flex-1 gap-1 sm:flex">
{doneCount > 0 && (
<>
<span>{doneCount} done</span> &bull;
</>
)}
{learningCount > 0 && (
<>
<span>{learningCount} in progress</span> &bull;
</>
)}
{skippedCount > 0 && (
<>
<span>{skippedCount} skipped</span> &bull;
</>
)}
<span>{totalCount} total</span>
</span>
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
<ProgressShareButton
resourceType={resourceType}
resourceId={resourceId}
isCustomResource={isCustomResource}
className="text-xs font-normal"
shareIconClassName="w-2.5 h-2.5 stroke-2"
checkIconClassName="w-2.5 h-2.5"
/>
<span className={'hidden sm:block'}>&bull;</span>
<div className="absolute right-2 top-0 flex h-full items-center">
<ResourceProgressActions
userId={userId!}
resourceType={resourceType}
resourceId={resourceId}
isCustomResource={isCustomResource}
onCleared={onCleared}
showClearButton={showClearButton}
/>
{showClearButton && (
<>
{!isConfirming && (
<button
className="text-red-500 hover:text-red-800"
onClick={() => setIsConfirming(true)}
disabled={isClearing}
>
{!isClearing && (
<>
Clear Progress <span>&times;</span>
</>
)}
{isClearing && 'Processing...'}
</button>
)}
{isConfirming && (
<span>
Are you sure?{' '}
<button
onClick={clearProgress}
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
>
Yes
</button>{' '}
<button
onClick={() => setIsConfirming(false)}
className="text-red-500 underline hover:text-red-800"
>
No
</button>
</span>
)}
</>
)}
</div>
</div>
</div>
);

View File

@@ -1,132 +0,0 @@
import { MoreVertical, X } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
import type { ResourceType } from '../../lib/resource-progress';
import { httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
type ResourceProgressActionsType = {
userId: string;
resourceType: ResourceType;
resourceId: string;
isCustomResource: boolean;
showClearButton?: boolean;
onCleared?: () => void;
};
export function ResourceProgressActions(props: ResourceProgressActionsType) {
const {
userId,
resourceType,
resourceId,
isCustomResource,
showClearButton = true,
onCleared,
} = props;
const toast = useToast();
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
async function clearProgress() {
setIsClearing(true);
const { error, response } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`,
{
resourceId,
resourceType,
},
);
if (error || !response) {
toast.error('Error clearing progress. Please try again.');
console.error(error);
setIsClearing(false);
return;
}
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`);
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`);
setIsClearing(false);
setIsConfirming(false);
if (onCleared) {
onCleared();
}
}
useOutsideClick(dropdownRef, () => {
setIsOpen(false);
});
useKeydown('Escape', () => {
setIsOpen(false);
});
return (
<div className="relative h-full" ref={dropdownRef}>
<button
className="h-full text-gray-400 hover:text-gray-700"
onClick={() => setIsOpen(!isOpen)}
>
<MoreVertical size={16} />
</button>
{isOpen && (
<div className="absolute right-0 top-8 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
<ProgressShareButton
resourceType={resourceType}
resourceId={resourceId}
isCustomResource={isCustomResource}
className="w-full gap-1.5 p-2 hover:bg-gray-100"
/>
{showClearButton && (
<>
{!isConfirming && (
<button
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
onClick={() => setIsConfirming(true)}
disabled={isClearing}
>
{!isClearing ? (
<>
<X className="h-3.5 w-3.5" />
Clear Progress
</>
) : (
'Processing...'
)}
</button>
)}
{isConfirming && (
<span className="flex w-full items-center justify-between gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70">
Are you sure?
<div className="flex items-center gap-2">
<button
onClick={clearProgress}
className="text-red-500 underline hover:text-red-800"
>
Yes
</button>
<button
onClick={() => setIsConfirming(false)}
className="text-red-500 underline hover:text-red-800"
>
No
</button>
</div>
</span>
)}
</>
)}
</div>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -56,11 +56,10 @@ export function hideRoadmapLoader() {
type CustomRoadmapProps = {
isEmbed?: boolean;
slug?: string;
};
export function CustomRoadmap(props: CustomRoadmapProps) {
const { isEmbed = false, slug } = props;
const { isEmbed = false } = props;
const { id, secret } = getUrlParams() as { id: string; secret: string };
@@ -71,11 +70,9 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
async function getRoadmap() {
setIsLoading(true);
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}`);
const roadmapUrl = new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`,
);
if (secret) {
roadmapUrl.searchParams.set('secret', secret);
@@ -116,12 +113,7 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
<>
{!isEmbed && <RoadmapHeader />}
<FlowRoadmapRenderer isEmbed={isEmbed} roadmap={roadmap!} />
<TopicDetail
resourceTitle={roadmap!.title}
resourceType="roadmap"
isEmbed={isEmbed}
canSubmitContribution={false}
/>
<TopicDetail isEmbed={isEmbed} canSubmitContribution={false} />
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -91,7 +91,7 @@ export function SharedRoadmapList(props: SharedRoadmapListProps) {
className="relative flex w-full border-t"
>
<a
href={`/r/=${roadmap?.slug}`}
href={`/r?id=${roadmap._id}`}
className="group inline-grid w-full grid-cols-[auto,16px] items-center justify-between gap-2 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-black"
target={'_blank'}
>

View File

@@ -11,7 +11,7 @@ const { heading, guides } = Astro.props;
---
<div class='container'>
<h2 class='text-2xl sm:text-3xl font-bold block'>{heading}</h2>
<h1 class='text-2xl sm:text-3xl font-bold block'>{heading}</h1>
<div class='mt-3 sm:my-5'>
{guides.map((guide) => <GuideListItem guide={guide} />)}

View File

@@ -11,7 +11,7 @@ const { heading, videos } = Astro.props;
---
<div class='container'>
<h2 class='text-2xl sm:text-3xl font-bold block'>{heading}</h2>
<h1 class='text-2xl sm:text-3xl font-bold block'>{heading}</h1>
<div class='mt-3 sm:my-5'>
{videos.map((video) => <VideoListItem video={video} />)}

View File

@@ -69,10 +69,6 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
return [];
}
if (trimmedValue.length < 3) {
return [];
}
if (termCache.has(trimmedValue)) {
const cachedData = termCache.get(trimmedValue);
return cachedData || [];
@@ -250,7 +246,7 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
key={result?._id}
type="button"
className={cn(
'flex w-full items-start rounded p-2 text-left text-sm',
'flex w-full items-center rounded p-2 text-sm',
counter === activeCounter ? 'bg-gray-100' : '',
)}
onMouseOver={() => setActiveCounter(counter)}
@@ -268,7 +264,7 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
>
<span
className={cn(
'mr-2 whitespace-nowrap rounded-full p-1 px-1.5 text-xs leading-none',
'mr-2 rounded-full p-1 px-1.5 text-xs leading-none',
result.isOfficial
? 'bg-green-500 text-green-50'
: 'bg-blue-400 text-blue-50',

View File

@@ -1,61 +0,0 @@
---
import { getGuideTableOfContent, type GuideFileType } from '../../lib/guide';
import MarkdownFile from '../MarkdownFile.astro';
import { TableOfContent } from '../TableOfContent/TableOfContent';
interface Props {
guide: GuideFileType;
}
const { guide } = Astro.props;
const allHeadings = guide.getHeadings();
const tableOfContent = getGuideTableOfContent(allHeadings);
const showTableOfContent = tableOfContent.length > 0;
const { frontmatter: guideFrontmatter, author } = guide;
---
<article class='lg:grid lg:max-w-full lg:grid-cols-[1fr_minmax(0,700px)_1fr]'>
{
showTableOfContent && (
<div class='bg-gradient-to-r from-gray-50 py-0 lg:col-start-3 lg:col-end-4 lg:row-start-1'>
<TableOfContent toc={tableOfContent} client:load />
</div>
)
}
<div
class:list={['col-start-2 col-end-3 row-start-1 mx-auto max-w-[700px] py-5 sm:py-10', {
'lg:border-r': showTableOfContent
}]}
>
<MarkdownFile>
<h1 class='text-balance text-4xl mb-3 font-bold'>{guideFrontmatter.title}</h1>
<p
class='flex items-center justify-start text-sm text-gray-400 my-0'
>
<a
href={`/authors/${author.id}`}
class='inline-flex items-center font-medium hover:text-gray-600 hover:underline underline-offset-2'
>
<img
alt={author.frontmatter.name}
src={author.frontmatter.imageUrl}
class='mb-0 mr-2 inline h-5 w-5 rounded-full'
/>
{author.frontmatter.name}
</a>
<span class='mx-2 hidden sm:inline'>&middot;</span>
<a
class='hover:text-gray-600 underline-offset-2 hidden sm:inline'
href={`https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/guides/${guide.id}.md`}
target='_blank'
>
Improve this Guide
</a>
</p>
<guide.Content />
</MarkdownFile>
</div>
</article>

View File

@@ -7,8 +7,6 @@ export interface Props {
const { guide } = Astro.props;
const { frontmatter, author } = guide;
return undefined;
---
<div class='border-b bg-white py-5 sm:py-12'>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<div
class='container prose-h2:text-balance prose-h3:text-balance prose-h4:text-balance prose-h5:text-balance prose prose-xl prose-h2:mb-3 prose-h2:mt-10 prose-h2:scroll-mt-5 prose-h2:text-3xl prose-h3:mt-2 prose-h3:scroll-mt-5 prose-h5:font-medium prose-blockquote:font-normal prose-code:bg-transparent prose-img:mt-1 prose-h2:sm:scroll-mt-10 prose-h3:sm:scroll-mt-10'
class='prose-xl prose-blockquote:font-normal prose container prose-code:bg-transparent prose-h2:text-3xl prose-h2:mt-10 prose-h2:mb-3 prose-h5:font-medium prose-h3:mt-2 prose-img:mt-1'
>
<slot />
</div>

View File

@@ -1,12 +1,4 @@
import {
ChevronRight,
LogOut,
Map,
Plus,
SquareUserRound,
User2,
Users2,
} from 'lucide-react';
import { ChevronRight, LogOut, Map, Plus, User2, Users2 } from 'lucide-react';
import { logout } from './navigation';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
import { useState } from 'react';
@@ -18,6 +10,7 @@ type AccountDropdownListProps = {
export function AccountDropdownList(props: AccountDropdownListProps) {
const { setIsTeamsOpen, onCreateRoadmap } = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
return (
<ul>
@@ -27,21 +20,7 @@ export function AccountDropdownList(props: AccountDropdownListProps) {
className="group flex items-center gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<User2 className="h-4 w-4 stroke-[2.5px] text-slate-400 group-hover:text-white" />
Account
</a>
</li>
<li className="px-1">
<a
href="/account/update-profile"
className="group flex items-center justify-between gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<span className="flex items-center gap-2">
<SquareUserRound className="h-4 w-4 stroke-[2.5px] text-slate-400 group-hover:text-white" />
My Profile
</span>
<span className="rounded-sm bg-yellow-300 px-1 text-xs uppercase tracking-wide text-black">
New
</span>
Profile
</a>
</li>
<li className="px-1">
@@ -87,7 +66,7 @@ export function AccountDropdownList(props: AccountDropdownListProps) {
</li>
<li className="px-1">
<button
className="group flex w-full items-center gap-2 rounded py-2 pl-3 pr-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700"
className="group flex gap-2 items-center w-full rounded py-2 pl-3 pr-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700"
type="button"
onClick={logout}
>

View File

@@ -17,10 +17,12 @@ import { AccountDropdown } from './AccountDropdown';
</a>
<a
href='/ai'
target='_blank'
rel='noreferrer nofollow'
href='https://boards.greenhouse.io/insightmediagroupllc/jobs/4002116008'
class='group inline sm:hidden relative !mr-2 text-blue-300 hover:text-white'
>
AI Roadmaps&nbsp;
We're Hiring
<span class='absolute -right-[11px] top-0'>
<span class='relative flex h-2 w-2'>
@@ -41,10 +43,12 @@ import { AccountDropdown } from './AccountDropdown';
</a>
<a href='/teams' class='text-gray-400 hover:text-white'> Teams</a>
<a
href='/ai'
target='_blank'
rel='noreferrer nofollow'
href='https://boards.greenhouse.io/insightmediagroupllc/jobs/4002116008'
class='group relative !mr-2 text-blue-300 hover:text-white'
>
AI Roadmaps
We're Hiring
<span class='absolute -right-[11px] top-0'>
<span class='relative flex h-2 w-2'>

View File

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

View File

@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react';
import { httpGet, httpPatch, httpPost } from '../lib/http';
import { httpGet } from '../lib/http';
import { sponsorHidden } from '../stores/page';
import { useStore } from '@nanostores/react';
import { X } from 'lucide-react';
import { setViewSponsorCookie } from '../lib/jwt';
export type PageSponsorType = {
company: string;
@@ -16,7 +15,6 @@ export type PageSponsorType = {
};
type V1GetSponsorResponse = {
id?: string;
href?: string;
sponsor?: PageSponsorType;
};
@@ -28,8 +26,6 @@ type PageSponsorProps = {
export function PageSponsor(props: PageSponsorProps) {
const { gaPageIdentifier } = props;
const $isSponsorHidden = useStore(sponsorHidden);
const [sponsorId, setSponsorId] = useState<string | null>(null);
const [sponsor, setSponsor] = useState<PageSponsorType>();
const loadSponsor = async () => {
@@ -63,7 +59,6 @@ export function PageSponsor(props: PageSponsorProps) {
}
setSponsor(response.sponsor);
setSponsorId(response?.id || null);
window.fireEvent({
category: 'SponsorImpression',
@@ -74,20 +69,6 @@ export function PageSponsor(props: PageSponsorProps) {
});
};
const clickSponsor = async (sponsorId: string) => {
const { response, error } = await httpPatch<{ status: 'ok' }>(
`${import.meta.env.PUBLIC_API_URL}/v1-view-sponsor/${sponsorId}`,
{},
);
if (error || !response) {
console.error(error);
return;
}
setViewSponsorCookie(sponsorId);
};
useEffect(() => {
window.setTimeout(loadSponsor);
}, []);
@@ -104,13 +85,12 @@ export function PageSponsor(props: PageSponsorProps) {
target="_blank"
rel="noopener sponsored nofollow"
className="fixed bottom-[15px] right-[15px] z-50 flex max-w-[350px] bg-white shadow-lg outline-0 outline-transparent"
onClick={async () => {
onClick={() => {
window.fireEvent({
category: 'SponsorClick',
action: `${company} Redirect`,
label: gaLabel || `${gaPageIdentifier} / ${company} Link`,
});
await clickSponsor(sponsorId || '');
}}
>
<span

View File

@@ -1,19 +0,0 @@
type YouTubeIconProps = {
className?: string;
};
export function YouTubeIcon(props: YouTubeIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0C.488 3.45.029 5.804 0 12c.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0C23.512 20.55 23.971 18.196 24 12c-.029-6.185-.484-8.549-4.385-8.816zM9 16V8l8 3.993L9 16z" />
</svg>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,98 +0,0 @@
import { useState, type CSSProperties } from 'react';
import type { HeadingGroupType } from '../../lib/guide';
import { ChevronDown } from 'lucide-react';
import { cn } from '../../lib/classname';
type TableOfContentProps = {
toc: HeadingGroupType[];
};
export function TableOfContent(props: TableOfContentProps) {
const { toc } = props;
const [isOpen, setIsOpen] = useState(false);
if (toc.length === 0) {
return null;
}
const totalRows = toc.flatMap((heading) => {
return [heading, ...heading.children];
}).length;
return (
<div
className={cn(
'relative min-w-[250px] px-5 pt-0 max-lg:min-w-full max-lg:max-w-full max-lg:border-none max-lg:px-0 lg:pt-10',
{
'top-0 lg:!sticky': totalRows <= 20,
},
)}
>
<h4 className="text-lg font-medium max-lg:hidden">In this article</h4>
<button
className="flex w-full items-center justify-between gap-2 bg-gray-300 px-3 py-2 text-sm font-medium lg:hidden"
onClick={() => setIsOpen(!isOpen)}
>
Table of Contents
<ChevronDown
size={16}
className={cn(
'transform transition-transform',
isOpen && 'rotate-180',
)}
/>
</button>
<ol
className={cn(
'mt-0.5 max-lg:absolute max-lg:top-full max-lg:mt-0 max-lg:w-full space-y-0 max-lg:bg-white max-lg:shadow',
!isOpen && 'hidden lg:block',
isOpen && 'block',
)}
>
{toc.map((heading) => (
<li key={heading.slug}>
<a
href={`#${heading.slug}`}
className="text-sm text-gray-500 no-underline hover:text-black max-lg:block max-lg:border-b max-lg:px-3 max-lg:py-1"
onClick={() => {
if (!isOpen) {
return;
}
setIsOpen(false);
}}
>
{heading.text}
</a>
{heading.children.length > 0 && (
<ol className="my-0 ml-4 mt-1 max-lg:ml-0 max-lg:mt-0 max-lg:list-none space-y-0">
{heading.children.map((children) => {
return (
<li key={children.slug}>
<a
href={`#${children.slug}`}
className="text-sm text-gray-500 no-underline hover:text-black max-lg:block max-lg:border-b max-lg:px-3 max-lg:py-1 max-lg:pl-8"
onClick={() => {
if (!isOpen) {
return;
}
setIsOpen(false);
}}
>
{children.text}
</a>
</li>
);
})}
</ol>
)}
</li>
))}
</ol>
</div>
);
}

View File

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

View File

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

View File

@@ -473,7 +473,7 @@ export function TeamRoadmaps() {
)}
<a
href={`/r/${resourceConfig.roadmapSlug}`}
href={`/r?id=${resourceConfig.resourceId}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
}

View File

@@ -27,13 +27,8 @@ import { Ban, FileText, X } from 'lucide-react';
import { getUrlParams } from '../../lib/browser';
import { Spinner } from '../ReactIcons/Spinner';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx';
import { YouTubeIcon } from '../ReactIcons/YouTubeIcon.tsx';
type TopicDetailProps = {
resourceTitle?: string;
resourceType?: ResourceType;
isEmbed?: boolean;
canSubmitContribution: boolean;
};
@@ -48,7 +43,7 @@ const linkTypes: Record<AllowedLinkTypes, string> = {
};
export function TopicDetail(props: TopicDetailProps) {
const { canSubmitContribution, isEmbed = false, resourceTitle } = props;
const { canSubmitContribution, isEmbed = false } = props;
const [hasEnoughLinks, setHasEnoughLinks] = useState(false);
const [contributionUrl, setContributionUrl] = useState('');
@@ -58,7 +53,6 @@ export function TopicDetail(props: TopicDetailProps) {
const [error, setError] = useState('');
const [topicHtml, setTopicHtml] = useState('');
const [topicTitle, setTopicTitle] = useState('');
const [topicHtmlTitle, setTopicHtmlTitle] = useState('');
const [links, setLinks] = useState<RoadmapContentDocument['links']>([]);
const toast = useToast();
@@ -174,11 +168,8 @@ export function TopicDetail(props: TopicDetailProps) {
topicDom.querySelector('[data-github-url]')!;
const contributionUrl = urlElem?.dataset?.githubUrl || '';
const titleElem: HTMLElement = topicDom.querySelector('h1')!;
setContributionUrl(contributionUrl);
setHasEnoughLinks(links.length >= 3);
setTopicHtmlTitle(titleElem?.textContent || '');
} else {
setLinks((response as RoadmapContentDocument)?.links || []);
setTopicTitle((response as RoadmapContentDocument)?.title || '');
@@ -207,8 +198,6 @@ export function TopicDetail(props: TopicDetailProps) {
}
const hasContent = topicHtml?.length > 0 || links?.length > 0 || topicTitle;
const googleSearchUrl = `https://www.google.com/search?q=${topicHtmlTitle?.toLowerCase()} guide for ${resourceTitle?.toLowerCase()}`;
const youtubeSearchUrl = `https://www.youtube.com/results?search_query=${topicHtmlTitle?.toLowerCase()} for ${resourceTitle?.toLowerCase()}`;
return (
<div className={'relative z-50'}>
@@ -299,32 +288,6 @@ export function TopicDetail(props: TopicDetailProps) {
</ul>
)}
{canSubmitContribution && (
<div>
<p className='text-base text-gray-700'>
Use the search links below to find more resources on this topic.
</p>
<div className="mt-3 flex gap-2">
<a
href={googleSearchUrl}
target="_blank"
className="flex items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 pl-2 text-sm hover:border-gray-700 hover:bg-gray-100"
>
<GoogleIcon className={'h-4 w-4'} />
Google
</a>
<a
href={youtubeSearchUrl}
target="_blank"
className="flex items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 pl-2 text-sm hover:border-gray-700 hover:bg-gray-100"
>
<YouTubeIcon className={'h-4 w-4 text-red-500'} />
YouTube
</a>
</div>
</div>
)}
{/* Contribution */}
{canSubmitContribution && !hasEnoughLinks && contributionUrl && (
<div className="mt-8 flex-1 border-t">

View File

@@ -1,151 +0,0 @@
import { useEffect, useState } from 'react';
import type { AllowedProfileVisibility } from '../../api/user';
import { httpGet, httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { CheckIcon, Loader2, X, XCircle } from 'lucide-react';
import { useDebounceValue } from '../../hooks/use-debounce.ts';
type ProfileUsernameProps = {
username: string;
setUsername: (username: string) => void;
profileVisibility: AllowedProfileVisibility;
currentUsername?: string;
};
export function ProfileUsername(props: ProfileUsernameProps) {
const { username, setUsername, profileVisibility, currentUsername } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isUnique, setIsUnique] = useState<boolean | null>(null);
const debouncedUsername = useDebounceValue(username, 500);
useEffect(() => {
checkIsUnique(debouncedUsername).then();
}, [debouncedUsername]);
const checkIsUnique = async (username: string) => {
if (isLoading || !username) {
return;
}
if (username.length < 3) {
setIsUnique(false);
return;
}
if (currentUsername && username === currentUsername && isUnique !== false) {
setIsUnique(true);
return;
}
setIsLoading(true);
const { response, error } = await httpPost<{
isUnique: boolean;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-check-is-unique-username`, {
username,
});
if (error || !response) {
setIsUnique(null);
setIsLoading(false);
toast.error(error?.message || 'Something went wrong. Please try again.');
return;
}
setIsUnique(response.isUnique);
setIsLoading(false);
};
return (
<div className="flex w-full flex-col">
<label
htmlFor="username"
className="flex min-h-[16.5px] items-center justify-between text-sm leading-none text-slate-500"
>
<span>Profile URL</span>
{!isLoading && (
<span className="flex items-center">
{currentUsername &&
(currentUsername === username || !username || !isUnique) && (
<span className="text-xs">
Current URL{' '}
<a
href={`${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/u/${currentUsername}`}
target="_blank"
className={
'ml-0.5 rounded-md border border-purple-500 px-1.5 py-0.5 font-mono text-xs font-medium text-purple-700 transition-colors hover:bg-purple-500 hover:text-white'
}
>
roadmap.sh/u/{currentUsername}
</a>
</span>
)}
{currentUsername !== username && username && isUnique && (
<span className="text-xs text-green-600">
URL after update{' '}
<a
href={`${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/u/${username}`}
target="_blank"
className={
'ml-0.5 rounded-md border border-purple-500 px-1.5 py-0.5 text-xs font-medium text-purple-700 transition-colors hover:bg-purple-500 hover:text-purple-800 hover:text-white'
}
>
roadmap.sh/u/{username}
</a>
</span>
)}
</span>
)}
</label>
<div className="mt-2 flex items-center overflow-hidden rounded-lg border border-gray-300">
<span className="border-r border-gray-300 bg-gray-100 p-2">
roadmap.sh/u/
</span>
<div className="relative grow">
<input
type="text"
name="username"
id="username"
className="w-full px-3 py-2 outline-none placeholder:text-gray-400"
placeholder="johndoe"
spellCheck={false}
value={username}
title="Username must be at least 3 characters long and can only contain letters, numbers, and underscores"
onKeyDown={(e) => {
// only allow letters, numbers
const keyCode = e.key;
const validKey =
/^[a-zA-Z0-9]*$/.test(keyCode) && username.length < 10;
if (
!validKey &&
!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight'].includes(
keyCode,
)
) {
e.preventDefault();
}
}}
onChange={(e) => {
setUsername((e.target as HTMLInputElement).value.toLowerCase());
}}
required={profileVisibility === 'public'}
/>
{username && (
<span className="absolute bottom-0 right-0 top-0 flex items-center px-2">
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isUnique === false ? (
<X className="h-4 w-4 text-red-500" />
) : isUnique === true ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : null}
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,48 +0,0 @@
import { CheckCircle, FileBadge } from 'lucide-react';
const ideas = [
'Add a link to your profile in your social media bio',
'Include your profile link in your resume to showcase your skills',
'Add a link to your profile in your email signature',
'Showcase your skills in your GitHub profile',
'Share your profile with potential employers',
];
export function SkillProfileAlert() {
return (
<div className="relative mb-5 rounded-lg bg-yellow-200 px-3 py-3 text-sm text-yellow-800">
<FileBadge className="absolute hidden sm:block bottom-3 right-3 h-20 w-20 stroke-2 text-yellow-500 opacity-50" />
<h2 className="mb-1 text-base font-semibold">
Announcing Skill Profiles!{' '}
</h2>
<p className="text-sm">
Create your skill profile to showcase your skills or learning progress.
Here are some of the ways you can use your skill profile:
</p>
<div className="my-3 ml-2 flex flex-col gap-1 sm:ml-3">
{ideas.map((idea) => (
<p
key={idea}
className="flex flex-row items-start gap-1.5 sm:items-center"
>
<CheckCircle className="relative top-[3px] h-3.5 w-3.5 flex-shrink-0 stroke-[2.5] sm:top-0" />
<span>{idea}</span>
</p>
))}
</div>
<p className="text-sm">
Make sure to mark your expertise{' '}
<a
href="/roadmaps"
target="_blank"
className="font-semibold underline underline-offset-2"
>
in the roadmaps.
</a>
</p>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,38 +0,0 @@
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
import { Spinner } from '../ReactIcons/Spinner';
type ModalLoaderProps = {
isLoading: boolean;
error?: string;
text: string;
};
export function ModalLoader(props: ModalLoaderProps) {
const { isLoading, text, error } = props;
return (
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto flex h-full w-full items-center justify-center">
<div className="popup-body relative rounded-lg bg-white p-5 shadow">
<div className="flex items-center">
{isLoading && (
<>
<Spinner className="h-6 w-6" isDualRing={false} />
<span className="ml-3 text-lg font-semibold">
{text || 'Loading...'}
</span>
</>
)}
{error && (
<>
<ErrorIcon additionalClasses="h-6 w-6 text-red-500" />
<span className="ml-3 text-lg font-semibold">{error}</span>
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { ErrorIcon } from "../ReactIcons/ErrorIcon";
import { Spinner } from "../ReactIcons/Spinner";
type ProgressLoadingErrorProps = {
isLoading: boolean;
error: string;
}
export function ProgressLoadingError(props: ProgressLoadingErrorProps) {
const { isLoading, error } = props;
return (
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto flex h-full w-full items-center justify-center">
<div className="popup-body relative rounded-lg bg-white p-5 shadow">
<div className="flex items-center">
{isLoading && (
<>
<Spinner className="h-6 w-6" isDualRing={false} />
<span className="ml-3 text-lg font-semibold">
Loading user progress...
</span>
</>
)}
{error && (
<>
<ErrorIcon additionalClasses="h-6 w-6 text-red-500" />
<span className="ml-3 text-lg font-semibold">{error}</span>
</>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -8,7 +8,7 @@ import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { useAuth } from '../../hooks/use-auth';
import type { GetRoadmapResponse } from '../CustomRoadmap/CustomRoadmap';
import { ReadonlyEditor } from '../../../editor/readonly-editor';
import { ModalLoader } from './ModalLoader.tsx';
import { ProgressLoadingError } from './ProgressLoadingError';
import { UserProgressModalHeader } from './UserProgressModalHeader';
import { X } from 'lucide-react';
@@ -144,13 +144,7 @@ export function UserCustomProgressModal(props: ProgressMapProps) {
}
if (isLoading || error) {
return (
<ModalLoader
text={'Loading user progress..'}
isLoading={isLoading}
error={error || ''}
/>
);
return <ProgressLoadingError isLoading={isLoading} error={error || ''} />;
}
return (

View File

@@ -8,7 +8,7 @@ import type { ResourceType } from '../../lib/resource-progress';
import { topicSelectorAll } from '../../lib/resource-progress';
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { useAuth } from '../../hooks/use-auth';
import { ModalLoader } from './ModalLoader.tsx';
import { ProgressLoadingError } from './ProgressLoadingError';
import { UserProgressModalHeader } from './UserProgressModalHeader';
import { X } from 'lucide-react';
@@ -187,13 +187,7 @@ export function UserProgressModal(props: ProgressMapProps) {
}
if (isLoading || error) {
return (
<ModalLoader
text={'Loading user progress..'}
isLoading={isLoading}
error={error}
/>
);
return <ProgressLoadingError isLoading={isLoading} error={error} />;
}
return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,97 +0,0 @@
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 (
<div>
{customRoadmapVisibility !== 'none' && customRoadmaps?.length > 0 && (
<div className="mb-5">
<h2 className="mb-2 text-xs uppercase tracking-wide text-gray-400">
Roadmaps made by me
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3">
{customRoadmaps.map((roadmap, counter) => {
const doneCount = roadmap.done;
const skippedCount = roadmap.skipped;
const totalCount = roadmap.total;
const totalMarked = doneCount + skippedCount;
const progressPercentage = getPercentage(totalMarked, totalCount);
return (
<a
target="_blank"
href={`/r/${roadmap.roadmapSlug}`}
key={roadmap.id + counter}
className="rounded-md border bg-white px-3 py-2 text-left text-sm shadow-sm transition-all hover:border-gray-300 hover:bg-gray-50"
>
{roadmap.title}
</a>
);
})}
</div>
</div>
)}
{roadmapVisibility !== 'none' && roadmaps.length > 0 && (
<>
<h2 className="mb-2 text-xs uppercase tracking-wide text-gray-400">
Skills I have mastered
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3">
{roadmaps.map((roadmap, counter) => {
const percentageDone = getPercentage(
roadmap.done + roadmap.skipped,
roadmap.total,
);
return (
<a
target="_blank"
key={roadmap.id + counter}
href={`/${roadmap.id}?s=${userId}`}
className="relative group border-gray-300 flex items-center justify-between rounded-md border bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400 overflow-hidden"
>
<span className="flex-grow truncate">{roadmap.title}</span>
<span className="text-xs text-gray-400">
{parseInt(percentageDone, 10)}%
</span>
<span
className="absolute transition-colors left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10"
style={{
width: `${percentageDone}%`,
}}
></span>
</a>
);
})}
</div>
</>
)}
</div>
);
}

View File

@@ -382,7 +382,7 @@ If you find yourself spending hours on the terminal, consider installing this co
Now, you suddenly have access to hundreds of [community-created extensions](https://www.raycast.com/store) that allow you to directly interact with chatGPT from the app launcher, use GitHub, interact with VSCode directly, and more.
![Raycast](https://assets.roadmap.sh/guest/raycast-app-launcher-w5rg3.png)
![Raycast](https://assets.roadmap.sh/guest/6529303b545cb53d4aa730ca_1710849400409.png)
While it is only available for macOS users, Raycast has become a must-have application for backend developers on this platform. In the end, the faster you can reach for your tools, the more productive you become. And a properly configured Raycast can make your web development process feel like a breeze.

View File

@@ -299,7 +299,7 @@ Lets take a look at some pros and cons for the last programming language on o
- Gos ecosystem is quite young when compared to the other alternatives here, so the maturity of the tools available might not be the same as, for example, Java or JavaScript tooling.
## Choosing the Ideal Backend Language
### Choosing the Ideal Backend Language
So, are these the best backend programming languages out there? Is there an absolute “best” backend programming language?
@@ -326,13 +326,13 @@ A handy tool when trying to evaluate a language like that is [roadmap.sh](https:
There youll find community-maintained roadmaps for many career paths within software development. In particular, for this article, the [backend roadmap](https://roadmap.sh/backend) is a great place to start, because while picking a backend language is important, youll see there that its not just about the language. In fact, there is a lot of tech around the language that is also required (Im referring to databases, git, understanding how client-server communication works, and a big “etc).
## Jumpstarting Your Backend Development Journey
### Jumpstarting Your Backend Development Journey
To get started with your backend development journey, it's crucial to have a roadmap that guides you through the learning process and equips you with the skills to build robust and scalable backend systems.
Lucky for you, if youre reading this, that means youve found the most complete and comprehensive roadmap online: [roadmap.sh](https://roadmap.sh), the current [backend roadmap](https://roadmap.sh/backend) is filled with details of everything you should and could (optionally) learn in your journey to becoming a backend developer.
## Guided Learning: From Online Courses to Bootcamps
### Guided Learning: From Online Courses to Bootcamps
Online courses and bootcamps serve as invaluable companions on your learning expedition. Platforms like Udemy, Coursera, and freeCodeCamp offer comprehensive backend development courses.
@@ -340,13 +340,13 @@ These resources not only cover programming languages like Python, Java, or JavaS
Whatever choice you go for, make sure youre not following trends or just copying the learning methods of others. Learning is a very personal experience and what works for others might not work for you, and vice versa. So make sure to do the proper research and figure out what option works best for you.
## Building Community Connections for Learning Support
### Building Community Connections for Learning Support
Joining developer communities (there are several on Twitter for example), forums like Stack Overflow, or participating in social media groups dedicated to backend development creates a network of support.
Engaging with experienced developers, sharing challenges, and seeking advice fosters a collaborative learning environment. Attend local meetups or virtual events if you can to connect with professionals in the field, gaining insights and building relationships that can prove invaluable throughout your journey.
## Think about you and your project
### Think about you and your project
There are many ways to go about picking the ideal backend language for you. If there is anything you should take home with you after reading this article, it is that most languages are equivalent in the sense that youll be able to do pretty much everything with any of them.
@@ -359,7 +359,7 @@ The questions you should also be asking yourself are:
In the end, personal preference and actual project requirements (if you have any) are very important, because both will influence how much you enjoy (or dont enjoy) the learning process.
## Crafting a Portfolio to Display Your Backend Skills:
### Crafting a Portfolio to Display Your Backend Skills:
As you accumulate skills and knowledge, showcase your journey through a well-crafted portfolio. Include projects that highlight your backend skills, demonstrating your ability to - design databases, implement server-side logic, and integrate with client side technologies. Whether it's a dynamic web application, a RESTful API, or a data-driven project, your portfolio becomes a tangible representation of your backend development capabilities for potential employers or collaborators.
@@ -367,7 +367,7 @@ When it comes to deciding where to publish this portfolio, you have some options
In the end, the important thing is that you should be sharing your experience somewhere, especially when you dont have working experience in the field.
## Conclusion
### Conclusion
In the end, there are many backend programming languages to choose from, and what language you go for, is up to you and your particular context/needs. All I can do is guide you to the door, but you have to cross it yourself. Some interesting options are:

View File

@@ -210,7 +210,7 @@ questions:
- 'Intermediate'
- question: What are Server Components in React?
answer: |
Server Components allow developers to write components that render on the server instead of the client. Unlike traditional components, Server Components do not have a client-side runtime, meaning they result in a smaller bundle size and faster loads. They can seamlessly integrate with client components and can fetch data directly from the backend without the need for an API layer. This enables developers to build rich, interactive apps with less client-side code, improving performance and developer experience.
Server Components in allow developers to write components that render on the server instead of the client. Unlike traditional components, Server Components do not have a client-side runtime, meaning they result in a smaller bundle size and faster loads. They can seamlessly integrate with client components and can fetch data directly from the backend without the need for an API layer. This enables developers to build rich, interactive apps with less client-side code, improving performance and developer experience.
topics:
- 'SSR'
- 'Intermediate'

View File

@@ -1,3 +1,3 @@
# If
# Basic Functions of a Data Analyst
The IF function in Excel is a crucial tool for data analysts, enabling them to create conditional statements, clean and validate data, perform calculations based on specific conditions, create custom metrics, apply conditional formatting, automate tasks, and generate dynamic reports. Data analysts use IF to categorize data, handle missing values, calculate bonuses or custom metrics, highlight trends, and enhance visualizations, ultimately facilitating informed decision-making through data analysis.
A Data Analyst serves a pivotal role in the decision-making processes within an organization. The basic function of a data analyst involves collecting, processing, and performing statistical analyses of data. Their work encompasses understanding the nature of data, finding out the patterns and insights hidden within them, and communicating these findings in a manner that can facilitate the decision-making of the company. They are often tasked to transform complex data into a format that is easily understandable, which enables the company to make informed decisions. This may involve designing and maintaining databases and data systems, conducting analysis to identify trends, and creating visualizations of their findings. These basic functions are the cornerstones upon which a data analyst builds more complex and organization-specific responsibilities from.

View File

@@ -11,6 +11,6 @@ Visit the following resources to learn more:
- [Learn the ways of Linux-fu, for free](https://linuxjourney.com/)
- [Linux Operating System - Crash Course for Beginners](https://www.youtube.com/watch?v=ROjZy1WbCIA)
- [The Linux Command Line by William Shotts](https://linuxcommand.org/tlcl.php)
- [Linux Upskill Challenge](https://linuxupskillchallenge.org/)
- [r/linuxupskillchallenge](https://www.reddit.com/r/linuxupskillchallenge/)
- [Introduction to Linux - Full Course for Beginners](https://www.youtube.com/watch?v=sWbUDq4S6Y8&pp=ygUTVWJ1bnR1IGNyYXNoIGNvdXJzZQ%3D%3D)
- [Linux Fundamentals](https://academy.hackthebox.com/course/preview/linux-fundamentals)
- [Linux Fundamentals](https://academy.hackthebox.com/course/preview/linux-fundamentals)

View File

@@ -6,7 +6,7 @@ This temporary or short-lived storage is called the "ephemeral container file sy
### Ephemeral FS and Data Persistence
As any data stored within the container's ephemeral FS is lost when the container is stopped and removed, it poses a challenge to data persistence in applications. This is especially problematic for applications like databases, which require data to be persisted across multiple container life cycles.
As any data stored within the container's ephemeral FS is lost when the container is stopped or removed, it poses a challenge to data persistence in applications. This is especially problematic for applications like databases, which require data to be persisted across multiple container life cycles.
To overcome these challenges, Docker provides several methods for data persistence, such as:
@@ -14,4 +14,4 @@ To overcome these challenges, Docker provides several methods for data persisten
- **Bind mounts**: Mapping a host machine's directory or file into a container, effectively sharing host's storage with the container.
- **tmpfs mounts**: In-memory storage, useful for cases where just the persistence of data within the life-cycle of the container is required.
By implementing these strategies, Docker ensures that application data can be preserved beyond the life-cycle of a single container, making it possible to work with stateful applications.
By implementing these strategies, Docker ensures that application data can be preserved beyond the life-cycle of a single container, making it possible to work with stateful applications.

View File

@@ -9,4 +9,4 @@ Lighthouse provides a comprehensive and easy-to-use tool for identifying and fix
Visit the following resources to learn more:
- [Lighthouse - Google Developers](https://developers.google.com/web/tools/lighthouse)
- [Improving Load Performance - Chrome DevTools 101](https://www.youtube.com/watch?v=5fLW5Q5ODiE)
- [Improving Load Performance - Chrome DevTools 101](https://www.youtube.com/watch?v=5flw5q5odie)

View File

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

View File

@@ -5,4 +5,4 @@ JavaScript was initially created by Brendan Eich of NetScape and was first annou
Visit the following resources to learn more:
- [Brief History of JavaScript](https://roadmap.sh/guides/history-of-javascript)
- [The Weird History of JavaScript](https://dev.to/codediodeio/the-weird-history-of-javascript-2bnb)
- [The History of JavaScript](https://dev.to/iarchitsharma/the-history-of-javascript-5e98)

View File

@@ -1,4 +1,4 @@
# Chatty I/O
# Chat I/O
The cumulative effect of a large number of I/O requests can have a significant impact on performance and responsiveness.

View File

@@ -1,9 +0,0 @@
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);
}

View File

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

View File

@@ -32,17 +32,3 @@ 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',
});
}

View File

@@ -87,33 +87,3 @@ export async function getGuideById(
return allGuides.find((guide) => guide.id === id);
}
type HeadingType = ReturnType<MarkdownFileType['getHeadings']>[number];
export type HeadingGroupType = HeadingType & { children: HeadingType[] };
const NUMBERED_LIST_REGEX = /^\d+\.\s+?/;
export function getGuideTableOfContent(headings: HeadingType[]) {
const tableOfContents: HeadingGroupType[] = [];
let currentGroup: HeadingGroupType | null = null;
headings
.filter((heading) => heading.depth !== 1)
.forEach((heading) => {
if (heading.depth === 2) {
currentGroup = {
...heading,
text: heading.text.replace(NUMBERED_LIST_REGEX, ''),
children: [],
};
tableOfContents.push(currentGroup);
} else if (currentGroup && heading.depth === 3) {
currentGroup.children.push({
...heading,
text: heading.text.replace(NUMBERED_LIST_REGEX, ''),
});
}
});
return tableOfContents;
}

View File

@@ -109,19 +109,3 @@ export function removeAIReferralCode() {
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
}
export function setViewSponsorCookie(sponsorId: string) {
const key = `vsc-${sponsorId}`;
const alreadyExist = Cookies.get(key);
if (alreadyExist) {
return;
}
Cookies.set(key, '1', {
path: '/',
expires: 1,
sameSite: 'lax',
secure: true,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
}

View File

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

View File

@@ -6,8 +6,3 @@ type RoadmapOpenGraphQuery = {
export function getOpenGraphImageUrl(params: RoadmapOpenGraphQuery) {
return `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/og-images/${params.group}/${params.resourceId}.png`;
}
export async function getDefaultOpenGraphImageBuffer() {
const defaultImageUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/images/og-img.png`;
return fetch(defaultImageUrl).then((response) => response.arrayBuffer());
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
---
import RoadmapBanner from '../../components/RoadmapBanner.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import {
getRoadmapTopicFiles,
type RoadmapTopicFileType,
@@ -34,4 +36,4 @@ const gitHubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste
<div data-github-url={gitHubUrl}></div>
<file.Content />
<file.Content />

View File

@@ -1,6 +1,7 @@
---
import FAQs, { type FAQType } from '../../components/FAQs/FAQs.astro';
import FAQs 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';
@@ -53,7 +54,7 @@ if (roadmapData.schema) {
}
if (roadmapFAQs.length) {
jsonLdSchema.push(generateFAQSchema(roadmapFAQs as unknown as FAQType[]));
jsonLdSchema.push(generateFAQSchema(roadmapFAQs));
}
const ogImageUrl =
@@ -106,12 +107,7 @@ const ogImageUrl =
description={roadmapData.briefDescription}
pageUrl={`https://roadmap.sh/${roadmapId}`}
/>
<TopicDetail
resourceTitle={roadmapData.title}
resourceType='roadmap'
client:idle
canSubmitContribution={true}
/>
<TopicDetail client:idle canSubmitContribution={true} />
<FrameRenderer
resourceType={'roadmap'}
@@ -129,7 +125,7 @@ const ogImageUrl =
client:only='react'
/>
<FAQs faqs={roadmapFAQs as unknown as FAQType[]} />
<FAQs faqs={roadmapFAQs} />
<RelatedRoadmaps roadmap={roadmapData} />
</div>

View File

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

View File

@@ -4,12 +4,8 @@ import { ActivityPage } from '../../components/Activity/ActivityPage';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout
title='Activity'
noIndex={true}
initialLoadingMessage={'Loading activity'}
>
<AccountLayout title='Update Profile' noIndex={true} initialLoadingMessage={'Loading activity'}>
<AccountSidebar activePageId='activity' activePageTitle='Activity'>
<ActivityPage client:only='react' />
<ActivityPage client:only="react" />
</AccountSidebar>
</AccountLayout>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
---
import GuideContent from '../../components/Guide/GuideContent.astro';
import GuideHeader from '../../components/GuideHeader.astro';
import MarkdownFile from '../../components/MarkdownFile.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getGuideById } from '../../lib/guide';
import { getOpenGraphImageUrl } from '../../lib/open-graph';
@@ -23,6 +23,11 @@ const ogImageUrl = getOpenGraphImageUrl({
canonicalUrl={guideData.canonicalUrl}
ogImageUrl={ogImageUrl}
>
<GuideHeader guide={guide!} />
<GuideContent guide={guide!} />
<GuideHeader guide={guide} />
<div class='mx-auto max-w-[700px] py-5 sm:py-10'>
<MarkdownFile>
<guide.Content />
</MarkdownFile>
</div>
</BaseLayout>

View File

@@ -1,6 +1,6 @@
---
import GuideContent from '../../components/Guide/GuideContent.astro';
import GuideHeader from '../../components/GuideHeader.astro';
import MarkdownFile from '../../components/MarkdownFile.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getGuideById } from '../../lib/guide';
import { getOpenGraphImageUrl } from '../../lib/open-graph';
@@ -23,6 +23,11 @@ const ogImageUrl = getOpenGraphImageUrl({
canonicalUrl={guideData.canonicalUrl}
ogImageUrl={ogImageUrl}
>
<GuideHeader guide={guide!} />
<GuideContent guide={guide!} />
<GuideHeader guide={guide} />
<div class='mx-auto max-w-[700px] py-5 sm:py-10'>
<MarkdownFile>
<guide.Content />
</MarkdownFile>
</div>
</BaseLayout>

View File

@@ -1,15 +1,12 @@
---
import GuideContent from '../../components/Guide/GuideContent.astro';
import GuideHeader from '../../components/GuideHeader.astro';
import MarkdownFile from '../../components/MarkdownFile.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getGuideById } from '../../lib/guide';
import { getOpenGraphImageUrl } from '../../lib/open-graph';
const guideId = 'backend-languages';
const guide = await getGuideById(guideId).catch(() => null);
if (!guide) {
return Astro.redirect('/404');
}
const guide = await getGuideById('backend-languages');
const { frontmatter: guideData } = guide!;
@@ -27,5 +24,10 @@ const ogImageUrl = getOpenGraphImageUrl({
ogImageUrl={ogImageUrl}
>
<GuideHeader guide={guide!} />
<GuideContent guide={guide!} />
<div class='mx-auto max-w-[700px] py-5 sm:py-10'>
<MarkdownFile>
<guide.Content />
</MarkdownFile>
</div>
</BaseLayout>

View File

@@ -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();
@@ -98,12 +98,7 @@ const ogImageUrl = getOpenGraphImageUrl({
pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`}
/>
<TopicDetail
resourceTitle={bestPracticeData.title}
resourceType='best-practice'
client:idle
canSubmitContribution={true}
/>
<TopicDetail client:idle canSubmitContribution={true} />
<FrameRenderer
resourceType={'best-practice'}

View File

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

View File

@@ -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-practice';
import { getAllBestPractices } from '../../lib/best-pratice';
const bestPractices = await getAllBestPractices();
---

View File

@@ -1,6 +1,6 @@
---
import GuideContent from '../../components/Guide/GuideContent.astro';
import GuideHeader from '../../components/GuideHeader.astro';
import MarkdownFile from '../../components/MarkdownFile.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getAllGuides, type GuideFileType } from '../../lib/guide';
import { getOpenGraphImageUrl } from '../../lib/open-graph';
@@ -38,5 +38,10 @@ const ogImageUrl = getOpenGraphImageUrl({
ogImageUrl={ogImageUrl}
>
<GuideHeader guide={guide} />
<GuideContent guide={guide!} />
<div class='mx-auto max-w-[700px] py-5 sm:py-10'>
<MarkdownFile>
<guide.Content />
</MarkdownFile>
</div>
</BaseLayout>

View File

@@ -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-practice';
import { getAllBestPractices } from '../lib/best-pratice';
import { getAllGuides } from '../lib/guide';
import { getRoadmapsByTag } from '../lib/roadmap';
import { getAllVideos } from '../lib/video';

View File

@@ -1,42 +0,0 @@
import type { APIRoute } from 'astro';
import { getDefaultOpenGraphImageBuffer } from '../../lib/open-graph';
export const prerender = false;
type Params = {
slug: string;
};
export const GET: APIRoute<any, Params> = async (context) => {
const { slug } = context.params;
if (!slug.startsWith('user-')) {
const buffer = await getDefaultOpenGraphImageBuffer();
return new Response(buffer, {
headers: {
'Content-Type': 'image/png',
},
});
}
const username = slug.replace('user-', '');
if (!username) {
const buffer = await getDefaultOpenGraphImageBuffer();
return new Response(buffer, {
headers: {
'Content-Type': 'image/png',
},
});
}
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-profile-open-graph/${username}`,
);
const svg = await response.text();
return new Response(svg, {
headers: {
'Content-Type': 'image/svg+xml',
},
});
};

View File

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

View File

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

View File

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

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