mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-16 20:01:43 +08:00
Compare commits
1 Commits
fix/og
...
feat/check
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1fad87eb6 |
105
.github/workflows/deployment.yml
vendored
105
.github/workflows/deployment.yml
vendored
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
359
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 293 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -30,8 +30,6 @@ Roadmaps are now interactive, you can click the nodes to read more about the top
|
||||
|
||||
Here is the list of available roadmaps with more being actively worked upon.
|
||||
|
||||
> Have a look at the [get started](https://roadmap.sh/get-started) page that might help you pick up a path.
|
||||
|
||||
- [Frontend Roadmap](https://roadmap.sh/frontend) / [Frontend Beginner Roadmap](https://roadmap.sh/frontend?r=frontend-beginner)
|
||||
- [Backend Roadmap](https://roadmap.sh/backend) / [Backend Beginner Roadmap](https://roadmap.sh/backend?r=backend-beginner)
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
|
||||
@@ -39,7 +37,6 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
|
||||
- [Data Structures and Algorithms Roadmap](https://roadmap.sh/datastructures-and-algorithms)
|
||||
- [AI and Data Scientist Roadmap](https://roadmap.sh/ai-data-scientist)
|
||||
- [Data Analyst Roadmap](https://roadmap.sh/data-analyst)
|
||||
- [MLOps Roadmap](https://roadmap.sh/mlops)
|
||||
- [QA Roadmap](https://roadmap.sh/qa)
|
||||
- [Python Roadmap](https://roadmap.sh/python)
|
||||
|
||||
@@ -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
|
||||
@@ -48,11 +48,6 @@ function getFilesInFolder(folderPath, fileList = {}) {
|
||||
return fileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the topic content for the given topic
|
||||
* @param currTopicUrl
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
function writeTopicContent(currTopicUrl) {
|
||||
const [parentTopic, childTopic] = currTopicUrl
|
||||
.replace(/^\d+-/g, '/')
|
||||
@@ -64,18 +59,9 @@ function writeTopicContent(currTopicUrl) {
|
||||
|
||||
const roadmapTitle = roadmapId.replace(/-/g, ' ');
|
||||
|
||||
let prompt = `I will give you a topic and you need to write a brief introduction for that with regards to "${roadmapTitle}". Your format should be as follows and be in strictly markdown format:
|
||||
|
||||
# (Put a heading for the topic)
|
||||
|
||||
(Write me a brief introduction for the topic with regards to "${roadmapTitle}")
|
||||
|
||||
`;
|
||||
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief paragraph for that. Your output should be strictly markdown. Do not include anything other than the description in your output. I already know the benefits of each so do not add benefits in the output.`;
|
||||
if (!childTopic) {
|
||||
prompt += `First topic is: ${parentTopic}`;
|
||||
} else {
|
||||
prompt += `First topic is: ${childTopic} under ${parentTopic}`;
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief paragraph for that. Your output should be strictly markdown. Do not include anything other than the description in your output. I already know the benefits of each so do not add benefits in the output.`;
|
||||
}
|
||||
|
||||
console.log(`Generating '${childTopic || parentTopic}'...`);
|
||||
@@ -137,9 +123,10 @@ async function writeFileForGroup(group, topicUrlToPathMapping) {
|
||||
}
|
||||
|
||||
const topicContent = await writeTopicContent(currTopicUrl);
|
||||
newFileContent += `\n\n${topicContent}`;
|
||||
|
||||
console.log(`Writing ${topicId}..`);
|
||||
fs.writeFileSync(contentFilePath, topicContent, 'utf8');
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
|
||||
// console.log(currentFileContent);
|
||||
// console.log(currTopicUrl);
|
||||
|
||||
153
src/api/api.ts
153
src/api/api.ts
@@ -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',
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
124
src/api/user.ts
124
src/api/user.ts
@@ -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,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || []} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<a href="/roadmaps" className="mt-4 text-blue-500 hover:underline">
|
||||
Roadmaps
|
||||
</a>
|
||||
,
|
||||
<a
|
||||
href="/best-practices"
|
||||
className="mt-4 text-blue-500 hover:underline"
|
||||
>
|
||||
Best Practices
|
||||
</a>
|
||||
or
|
||||
<a href="/questions" className="mt-4 text-blue-500 hover:underline">
|
||||
Questions
|
||||
</a>
|
||||
progress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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> •
|
||||
</>
|
||||
)}
|
||||
{learningCount > 0 && (
|
||||
<>
|
||||
<span>{learningCount} in progress</span> •
|
||||
</>
|
||||
)}
|
||||
{skippedCount > 0 && (
|
||||
<>
|
||||
<span>{skippedCount} skipped</span> •
|
||||
</>
|
||||
)}
|
||||
<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'}>•</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>×</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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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} />)}
|
||||
|
||||
@@ -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} />)}
|
||||
|
||||
@@ -51,7 +51,7 @@ import Icon from './AstroIcon.astro';
|
||||
href='https://kamranahmed.info'
|
||||
target='_blank'
|
||||
>
|
||||
<span class='hidden sm:inline'>Kamran</span>
|
||||
<span class='hidden sm:inline'>@kamrify</span>
|
||||
<span class='inline sm:hidden'>Kamran Ahmed</span>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { replaceChildren } from '../../lib/dom.ts';
|
||||
import {setUrlParams} from "../../lib/browser.ts";
|
||||
|
||||
export class Renderer {
|
||||
resourceId: string;
|
||||
@@ -142,8 +141,19 @@ export class Renderer {
|
||||
|
||||
const newJsonFileSlug = newJsonUrl.split('/').pop()?.replace('.json', '');
|
||||
|
||||
const type = this.resourceType[0]; // r for roadmap, b for best-practices
|
||||
setUrlParams({ [type]: newJsonFileSlug! })
|
||||
// Update the URL and attach the new roadmap type
|
||||
if (window?.history?.pushState) {
|
||||
const url = new URL(window.location.href);
|
||||
const type = this.resourceType[0]; // r for roadmap, b for best-practices
|
||||
|
||||
url.searchParams.delete(type);
|
||||
|
||||
if (newJsonFileSlug !== this.resourceId) {
|
||||
url.searchParams.set(type, newJsonFileSlug!);
|
||||
}
|
||||
|
||||
window.history.pushState(null, '', url.toString());
|
||||
}
|
||||
|
||||
this.jsonToSvg(newJsonUrl)?.then(() => {});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -113,9 +113,7 @@ export function GenerateRoadmap() {
|
||||
const [roadmapTopicLimitUsed, setRoadmapTopicLimitUsed] = useState(0);
|
||||
const [isConfiguring, setIsConfiguring] = useState(false);
|
||||
|
||||
const [openAPIKey, setOpenAPIKey] = useState<string | undefined>(
|
||||
getOpenAIKey(),
|
||||
);
|
||||
const [openAPIKey, setOpenAPIKey] = useState<string | undefined>(getOpenAIKey());
|
||||
const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION;
|
||||
const isAuthenticatedUser = isLoggedIn();
|
||||
|
||||
@@ -660,7 +658,7 @@ export function GenerateRoadmap() {
|
||||
</div>
|
||||
<div
|
||||
className={cn({
|
||||
'relative mb-20 max-h-[800px] min-h-[800px] overflow-hidden sm:max-h-[1000px] md:min-h-[1000px] lg:max-h-[1200px] lg:min-h-[1200px]':
|
||||
'relative mb-20 max-h-[800px] min-h-[800px] sm:max-h-[1000px] md:min-h-[1000px] lg:max-h-[1200px] lg:min-h-[1200px] overflow-hidden':
|
||||
!isAuthenticatedUser,
|
||||
})}
|
||||
>
|
||||
@@ -668,18 +666,18 @@ export function GenerateRoadmap() {
|
||||
ref={roadmapContainerRef}
|
||||
id="roadmap-container"
|
||||
onClick={handleNodeClick}
|
||||
className="relative min-h-[400px] px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
|
||||
className="relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
|
||||
/>
|
||||
{!isAuthenticatedUser && (
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<div className="h-80 w-full bg-gradient-to-t from-gray-100 to-transparent" />
|
||||
<div className="bg-gray-100">
|
||||
<div className="mx-auto max-w-[600px] flex-col items-center justify-center bg-gray-100 px-5 pt-px">
|
||||
<div className="mx-auto px-5 max-w-[600px] flex-col items-center justify-center bg-gray-100 pt-px">
|
||||
<div className="mt-8 text-center">
|
||||
<h2 className="mb-0.5 text-xl font-medium sm:mb-3 sm:text-2xl">
|
||||
<h2 className="mb-0.5 sm:mb-3 text-xl sm:text-2xl font-medium">
|
||||
Sign up to View the full roadmap
|
||||
</h2>
|
||||
<p className="mb-6 text-balance text-sm text-gray-600 sm:text-base">
|
||||
<p className="mb-6 text-sm sm:text-base text-gray-600 text-balance">
|
||||
You must be logged in to view the complete roadmap
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
||||
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow flex-col items-center px-4 py-6 sm:px-6 md:my-24 lg:my-32">
|
||||
<div className="flex flex-grow flex-col items-center px-4 py-6 sm:px-6">
|
||||
{isConfiguring && (
|
||||
<IncreaseRoadmapLimit
|
||||
onClose={() => {
|
||||
@@ -55,7 +55,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-0 text-center sm:gap-2">
|
||||
<div className="flex flex-col gap-0 text-center sm:gap-2 md:mt-24 lg:mt-32">
|
||||
<h1 className="relative text-2xl font-medium sm:text-3xl">
|
||||
<span className="hidden sm:inline">Generate roadmaps with AI</span>
|
||||
<span className="inline sm:hidden">AI Roadmap Generator</span>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function RoleRoadmaps(props: RoleRoadmapsProps) {
|
||||
<SectionBadge title={badge} />
|
||||
</div>
|
||||
<div className="my-4 sm:my-7 text-left">
|
||||
<h2 className="mb-1 text-balance text-xl sm:text-3xl font-semibold">{title}</h2>
|
||||
<h2 className="mb-1 text-xl sm:text-3xl font-semibold">{title}</h2>
|
||||
<p className="text-sm sm:text-base text-gray-500">{description}</p>
|
||||
|
||||
<div className="mt-4 sm:mt-7 grid sm:grid-cols-2 md:grid-cols-3 gap-3">{children}</div>
|
||||
|
||||
@@ -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'>·</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>
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
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'>
|
||||
|
||||
@@ -68,15 +68,14 @@ 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 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,
|
||||
'pointer-events-auto translate-y-2.5 opacity-100': isOpen,
|
||||
},
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ const discordInfo = await getDiscordInfo();
|
||||
</p>
|
||||
|
||||
<div
|
||||
class='mt-5 grid grid-cols-1 justify-between gap-2 divide-x-0 sm:my-11 sm:grid-cols-3 sm:gap-0 sm:divide-x mb-4 sm:mb-0'
|
||||
class='mt-5 grid grid-cols-1 justify-between gap-2 divide-x-0 sm:my-11 sm:grid-cols-3 sm:gap-0 sm:divide-x'
|
||||
>
|
||||
<OpenSourceStat text='GitHub Stars' value={starCount} />
|
||||
<OpenSourceStat text='Registered Users' value={'850k'} />
|
||||
|
||||
@@ -14,7 +14,7 @@ const isDiscordMembers = text.toLowerCase() === 'discord members';
|
||||
---
|
||||
|
||||
<div
|
||||
class='flex items-start sm:items-center justify-start flex-col sm:justify-center sm:gap-0 gap-2 sm:bg-transparent sm:rounded-none rounded-xl p-0 sm:p-4 mb-3 sm:mb-0'
|
||||
class='flex items-start sm:items-center justify-start flex-col sm:justify-center sm:gap-0 gap-2 sm:bg-transparent bg-gray-200 sm:rounded-none rounded-xl p-4'
|
||||
>
|
||||
{
|
||||
isGitHubStars && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
type CategoryFilterButtonProps = {
|
||||
category: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function CategoryFilterButton(props: CategoryFilterButtonProps) {
|
||||
const { category, selected, onClick } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'border-b bg-gradient-to-l py-1.5 pr-3 text-center text-sm text-gray-500 hover:text-gray-900 sm:text-right',
|
||||
{
|
||||
'from-white font-semibold text-gray-900':
|
||||
selected && category !== 'All Roadmaps',
|
||||
'font-semibold text-gray-900':
|
||||
selected && category === 'All Roadmaps',
|
||||
'hover:from-white': category !== 'All Roadmaps',
|
||||
},
|
||||
)}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import { CategoryFilterButton } from './CategoryFilterButton.tsx';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click.ts';
|
||||
|
||||
const groupNames = [
|
||||
'Absolute Beginners',
|
||||
'Web Development',
|
||||
'Languages / Platforms',
|
||||
'Frameworks',
|
||||
'Mobile Development',
|
||||
'Databases',
|
||||
'Computer Science',
|
||||
'Machine Learning',
|
||||
'Game Development',
|
||||
'Design',
|
||||
'DevOps',
|
||||
'Blockchain',
|
||||
'Cyber Security',
|
||||
];
|
||||
|
||||
type AllowGroupNames = (typeof groupNames)[number];
|
||||
|
||||
type GroupType = {
|
||||
group: AllowGroupNames;
|
||||
roadmaps: {
|
||||
title: string;
|
||||
link: string;
|
||||
type: 'role' | 'skill';
|
||||
otherGroups?: AllowGroupNames[];
|
||||
}[];
|
||||
};
|
||||
|
||||
const groups: GroupType[] = [
|
||||
{
|
||||
group: 'Absolute Beginners',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'Frontend Beginner',
|
||||
link: '/frontend?r=frontend-beginner',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Backend Beginner',
|
||||
link: '/backend?r=backend-beginner',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'DevOps Beginner',
|
||||
link: '/devops?r=devops-beginner',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Web Development',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'Frontend',
|
||||
link: '/frontend',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Backend',
|
||||
link: '/backend',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Full Stack',
|
||||
link: '/full-stack',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development', 'Absolute Beginners'],
|
||||
},
|
||||
{
|
||||
title: 'QA',
|
||||
link: '/qa',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'GraphQL',
|
||||
link: '/graphql',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Frameworks',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'React',
|
||||
link: '/react',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Vue',
|
||||
link: '/vue',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Angular',
|
||||
link: '/angular',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Spring Boot',
|
||||
link: '/spring-boot',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'ASP.NET Core',
|
||||
link: '/aspnet-core',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Languages / Platforms',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'JavaScript',
|
||||
link: '/javascript',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development', 'DevOps', 'Mobile Development'],
|
||||
},
|
||||
{
|
||||
title: 'TypeScript',
|
||||
link: '/typescript',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development', 'Mobile Development'],
|
||||
},
|
||||
{
|
||||
title: 'Node.js',
|
||||
link: '/nodejs',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development', 'DevOps'],
|
||||
},
|
||||
{
|
||||
title: 'C++',
|
||||
link: '/cpp',
|
||||
type: 'skill',
|
||||
},
|
||||
{
|
||||
title: 'Go',
|
||||
link: '/golang',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development', 'DevOps'],
|
||||
},
|
||||
{
|
||||
title: 'Rust',
|
||||
link: '/rust',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development', 'DevOps'],
|
||||
},
|
||||
{
|
||||
title: 'Python',
|
||||
link: '/python',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development', 'DevOps'],
|
||||
},
|
||||
{
|
||||
title: 'Java',
|
||||
link: '/java',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'SQL',
|
||||
link: '/sql',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development', 'Databases', 'DevOps'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'DevOps',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'DevOps',
|
||||
link: '/devops',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Docker',
|
||||
link: '/docker',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Kubernetes',
|
||||
link: '/kubernetes',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'AWS',
|
||||
link: '/aws',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mobile Development',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'Android',
|
||||
link: '/android',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'React Native',
|
||||
link: '/react-native',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'Flutter',
|
||||
link: '/flutter',
|
||||
type: 'role',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Databases',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'PostgreSQL',
|
||||
link: '/postgresql-dba',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'MongoDB',
|
||||
link: '/mongodb',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Computer Science',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'Computer Science',
|
||||
link: '/computer-science',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Data Structures',
|
||||
link: '/datastructures-and-algorithms',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'System Design',
|
||||
link: '/system-design',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Design and Architecture',
|
||||
link: '/software-design-architecture',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Software Architect',
|
||||
link: '/software-architect',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Code Review',
|
||||
link: '/code-review',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Technical Writer',
|
||||
link: '/technical-writer',
|
||||
type: 'role',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Machine Learning',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'AI and Data Scientist',
|
||||
link: '/ai-data-scientist',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'Data Analyst',
|
||||
link: '/data-analyst',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'MLOps',
|
||||
link: '/mlops',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'Prompt Engineering',
|
||||
link: '/prompt-engineering',
|
||||
type: 'skill',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Game Development',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'Client Side Game Dev.',
|
||||
link: '/game-developer',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'Server Side Game Dev.',
|
||||
link: '/server-side-game-developer',
|
||||
type: 'role',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Design',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'UX Design',
|
||||
link: '/ux-design',
|
||||
type: 'role',
|
||||
},
|
||||
{
|
||||
title: 'Design System',
|
||||
link: '/design-system',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Blockchain',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'Blockchain',
|
||||
link: '/blockchain',
|
||||
type: 'role',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Cyber Security',
|
||||
roadmaps: [
|
||||
{
|
||||
title: 'Cyber Security',
|
||||
link: '/cyber-security',
|
||||
type: 'role',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const roleRoadmaps = groups.flatMap((group) =>
|
||||
group.roadmaps.filter((roadmap) => roadmap.type === 'role'),
|
||||
);
|
||||
const skillRoadmaps = groups.flatMap((group) =>
|
||||
group.roadmaps.filter((roadmap) => roadmap.type === 'skill'),
|
||||
);
|
||||
|
||||
const allGroups = [
|
||||
{
|
||||
group: 'Role Based Roadmaps',
|
||||
roadmaps: roleRoadmaps,
|
||||
},
|
||||
{
|
||||
group: 'Skill Based Roadmaps',
|
||||
roadmaps: skillRoadmaps,
|
||||
},
|
||||
];
|
||||
|
||||
export function RoadmapsPage() {
|
||||
const [activeGroup, setActiveGroup] = useState<AllowGroupNames>('');
|
||||
const [visibleGroups, setVisibleGroups] = useState<GroupType[]>(allGroups);
|
||||
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeGroup) {
|
||||
setVisibleGroups(allGroups);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = groups.find((group) => group.group === activeGroup);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
// other groups that have a roadmap that is in the same group
|
||||
const otherGroups = groups.filter((g) => {
|
||||
return (
|
||||
g.group !== group.group &&
|
||||
g.roadmaps.some((roadmap) => {
|
||||
return roadmap.otherGroups?.includes(group.group);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
setVisibleGroups([
|
||||
group,
|
||||
...otherGroups.map((g) => ({
|
||||
...g,
|
||||
roadmaps: g.roadmaps.filter((roadmap) =>
|
||||
roadmap.otherGroups?.includes(group.group),
|
||||
),
|
||||
})),
|
||||
]);
|
||||
}, [activeGroup]);
|
||||
|
||||
return (
|
||||
<div className="border-t bg-gray-100">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsFilterOpen(!isFilterOpen);
|
||||
}}
|
||||
id="filter-button"
|
||||
className={cn(
|
||||
'-mt-1 flex w-full items-center justify-center bg-gray-300 py-2 text-sm text-black focus:shadow-none focus:outline-0 sm:hidden',
|
||||
{
|
||||
'mb-3': !isFilterOpen,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{!isFilterOpen && <Filter size={13} className="mr-1" />}
|
||||
{isFilterOpen && <X size={13} className="mr-1" />}
|
||||
Categories
|
||||
</button>
|
||||
<div className="container relative flex flex-col gap-4 sm:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
'hidden w-full flex-col from-gray-100 sm:w-[180px] sm:border-r sm:bg-gradient-to-l sm:pt-6',
|
||||
{
|
||||
'hidden sm:flex': !isFilterOpen,
|
||||
'z-50 flex': isFilterOpen,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="absolute top-0 -mx-4 w-full bg-white pb-0 shadow-xl sm:sticky sm:top-10 sm:mx-0 sm:bg-transparent sm:pb-20 sm:shadow-none">
|
||||
<div className="grid grid-cols-1">
|
||||
<CategoryFilterButton
|
||||
onClick={() => {
|
||||
setActiveGroup('');
|
||||
setIsFilterOpen(false);
|
||||
}}
|
||||
category={'All Roadmaps'}
|
||||
selected={activeGroup === ''}
|
||||
/>
|
||||
|
||||
{groups.map((group) => (
|
||||
<CategoryFilterButton
|
||||
key={group.group}
|
||||
onClick={() => {
|
||||
setActiveGroup(group.group);
|
||||
setIsFilterOpen(false);
|
||||
document?.getElementById('filter-button')?.scrollIntoView();
|
||||
}}
|
||||
category={group.group}
|
||||
selected={activeGroup === group.group}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col gap-6 pb-20 pt-2 sm:pt-8">
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={`${group.group}-${group.roadmaps.length}`}>
|
||||
<h2 className="mb-2 text-xs uppercase tracking-wide text-gray-400">
|
||||
{group.group}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3">
|
||||
{group.roadmaps.map((roadmap) => (
|
||||
<a
|
||||
key={roadmap.link}
|
||||
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"
|
||||
href={roadmap.link}
|
||||
>
|
||||
{roadmap.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import { showLoginPopup } from '../../lib/popup.ts';
|
||||
|
||||
export function RoadmapsPageHeader() {
|
||||
return (
|
||||
<div className="bg-white py-3 sm:py-12">
|
||||
<div className="container">
|
||||
<div className="flex flex-col items-start bg-white sm:items-center">
|
||||
<h1 className="text-2xl font-bold sm:text-5xl">Developer Roadmaps</h1>
|
||||
<p className="mb-3 mt-1 text-sm sm:my-3 sm:text-lg">
|
||||
Browse the ever-growing list of up-to-date, community driven
|
||||
roadmaps
|
||||
</p>
|
||||
<p className="mb-3 flex w-full flex-col gap-1.5 sm:mb-0 sm:w-auto sm:flex-row sm:gap-3">
|
||||
<a
|
||||
className="inline-block rounded-md bg-black px-3.5 py-2 text-sm text-white sm:py-1.5 sm:text-base"
|
||||
href="https://draw.roadmap.sh"
|
||||
onClick={(e) => {
|
||||
if (!isLoggedIn()) {
|
||||
e.preventDefault();
|
||||
showLoginPopup();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Draw your own roadmap
|
||||
</a>
|
||||
<a
|
||||
className="inline-block rounded-md bg-gray-300 px-3.5 py-2 text-sm text-black sm:py-1.5 sm:text-base"
|
||||
href="https://roadmap.sh/ai"
|
||||
onClick={(e) => {
|
||||
if (!isLoggedIn()) {
|
||||
e.preventDefault();
|
||||
showLoginPopup();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Generate Roadmaps with AI
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
37
src/components/UserProgress/ProgressLoadingError.tsx
Normal file
37
src/components/UserProgress/ProgressLoadingError.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">·</span>
|
||||
<span>
|
||||
<span>{learning.length}</span> in progress
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span>{skipped.length}</span> skipped
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ Let’s take a look at some pros and cons for the last programming language on o
|
||||
|
||||
- Go’s 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 you’ll 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, you’ll see there that it’s not just about the language. In fact, there is a lot of tech around the language that is also required (I’m 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 you’re reading this, that means you’ve 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 you’re 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 you’ll 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 don’t 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 don’t 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:
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
jsonUrl: '/jsons/roadmaps/ai-data-scientist.json'
|
||||
pdfUrl: '/pdfs/roadmaps/ai-data-scientist.pdf'
|
||||
order: 4
|
||||
order: 6
|
||||
briefTitle: 'AI and Data Scientist'
|
||||
briefDescription: 'Step by step guide to becoming an AI and Data Scientist in 2024'
|
||||
title: 'AI and Data Scientist Roadmap'
|
||||
description: 'Step by step guide to becoming an AI and Data Scientist in 2024'
|
||||
hasTopics: true
|
||||
isNew: false
|
||||
isNew: true
|
||||
dimensions:
|
||||
width: 968
|
||||
height: 2243.96
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
pdfUrl: '/pdfs/roadmaps/android.pdf'
|
||||
order: 5
|
||||
order: 4
|
||||
briefTitle: 'Android'
|
||||
briefDescription: 'Step by step guide to becoming an Android Developer in 2024'
|
||||
title: 'Android Developer'
|
||||
|
||||
@@ -57,5 +57,5 @@ sitemap:
|
||||
tags:
|
||||
- 'roadmap'
|
||||
- 'main-sitemap'
|
||||
- 'skill-roadmap'
|
||||
- 'role-roadmap'
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Eager Loading
|
||||
|
||||
Eager Loading helps you to load all your needed entities at once; i.e., all your child entities will be loaded at single database call. This can be achieved, using the Include method, which returns the related entities as a part of the query and a large amount of data is loaded at once.
|
||||
Eager Loading helps you to load all your needed entities at once; i.e., all your child entities will be loaded at single database call. This can be achieved, using the Include method, which returs the related entities as a part of the query and a large amount of data is loaded at once.
|
||||
|
||||
## Lazy Loading
|
||||
|
||||
|
||||
@@ -5,4 +5,4 @@ MediatR is an open-source library for .NET that is designed to simplify the proc
|
||||
For more information, visit the following links:
|
||||
|
||||
- [Use MediatR in ASP.NET or ASP.NET Core](https://medium.com/dotnet-hub/use-mediatr-in-asp-net-or-asp-net-core-cqrs-and-mediator-in-dotnet-how-to-use-mediatr-cqrs-aspnetcore-5076e2f2880c)
|
||||
- [How to implement CQRS using MediatR in an ASP.NET?](https://christian-schou.dk/blog/how-to-implement-cqrs-with-mediatr-in-asp-net/)
|
||||
- [How to implement CQRS using MediatR in an ASP.NET?](https://blog.christian-schou.dk/how-to-implement-cqrs-with-mediatr-in-asp-net/)
|
||||
|
||||
@@ -52,5 +52,5 @@ sitemap:
|
||||
tags:
|
||||
- 'roadmap'
|
||||
- 'main-sitemap'
|
||||
- 'skill-roadmap'
|
||||
- 'role-roadmap'
|
||||
---
|
||||
|
||||
@@ -32,8 +32,8 @@ The **Presentation layer** is responsible for translating or converting the data
|
||||
|
||||
The **Application layer** is the interface between the user and the communication system. It is responsible for providing networking services for various applications, like email, web browsing, or file sharing.
|
||||
|
||||
Each of these layers interacts with the adjacent layers to pass data packets back and forth. Understanding the OSI model is crucial for addressing potential security threats and vulnerabilities that can occur at each layer. By implementing strong network security measures at each layer, you can minimize the risk of cyber attacks and keep your data safe.
|
||||
Each of these layers interacts with the adjacent layers to pass data packets back and forth. Understanding the OCI model is crucial for addressing potential security threats and vulnerabilities that can occur at each layer. By implementing strong network security measures at each layer, you can minimize the risk of cyber attacks and keep your data safe.
|
||||
|
||||
In the next section, we will discuss network protocols and how they play an essential role in network communication and security.
|
||||
|
||||
- [What is OSI Model?](https://www.youtube.com/watch?v=Ilk7UXzV_Qc&ab_channel=RealPars)
|
||||
- [What is OSI Model?](https://www.youtube.com/watch?v=Ilk7UXzV_Qc&ab_channel=RealPars)
|
||||
@@ -1,3 +0,0 @@
|
||||
# Introduction to Data Analytics for Data Analysts
|
||||
|
||||
Data Analytics is a core component of a Data Analyst's role. The field involves extracting meaningful insights from raw data to drive decision-making processes. It includes a wide range of techniques and disciplines ranging from the simple data compilation to advanced algorithms and statistical analysis. As a data analyst, you are expected to understand and interpret complex digital data, such as the usage statistics of a website, the sales figures of a company, or client engagement over social media, etc. This knowledge enables data analysts to support businesses in identifying trends, making informed decisions, predicting potential outcomes - hence playing a crucial role in shaping business strategies.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Descriptive Analytics
|
||||
|
||||
Descriptive Analytics is one of the fundamental types of Data Analytics that provides insight into the past. As a Data Analyst, utilizing Descriptive Analytics involves the technique of using historical data to understand changes that have occurred in a business over time. Primarily concerned with the “what has happened” aspect, it analyzes raw data from the past to draw inferences and identify patterns and trends. This helps companies understand their strengths, weaknesses and pinpoint operational problems, setting the stage for accurate Business Intelligence and decision-making processes.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Diagnostic Analytics
|
||||
|
||||
Diagnostic analytics, as a crucial type of data analytics, is focused on studying past performance to understand why something happened. This is an integral part of the work done by data analysts. Through techniques such as drill-down, data discovery, correlations, and cause-effect analysis, data analysts utilizing diagnostic analytics can look beyond general trends and identify the root cause of changes observed in the data. Consequently, this enables businesses to address operational and strategic issues effectively, by allowing them to grasp the reasons behind such issues. For every data analyst, the skill of performing diagnostic data analytics is a must-have asset that enhances their analysis capability.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Predictive Analysis
|
||||
|
||||
Predictive analysis is a crucial type of data analytics that any competent data analyst should comprehend. It refers to the practice of extracting information from existing data sets in order to determine patterns and forecast future outcomes and trends. Data analysts apply statistical algorithms, machine learning techniques, and artificial intelligence to the data to anticipate future results. Predictive analysis enables organizations to be proactive, forward-thinking, and strategic by providing them valuable insights on future occurrences. It's a powerful tool that gives companies a significant competitive edge by enabling risk management, opportunity identification, and strategic decision-making.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Prescriptive Analytics
|
||||
|
||||
Prescriptive analytics, a crucial type of data analytics, is essential for making data-driven decisions in business and organizational contexts. As a data analyst, the goal of prescriptive analytics is to recommend various actions using predictions on the basis of known parameters to help decision makers understand likely outcomes. Prescriptive analytics employs a blend of techniques and tools such as algorithms, machine learning, computational modelling procedures, and decision-tree structures to enable automated decision making. Therefore, prescriptive analytics not only anticipates what will happen and when it will happen, but also explains why it will happen, contributing to the significance of a data analyst’s role in an organization.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Introduction to Types of Data Analytics
|
||||
|
||||
Data Analytics has proven to be a critical part of decision-making in modern business ventures. It is responsible for discovering, interpreting, and transforming data into valuable information. Different types of data analytics look at past, present, or predictive views of business operations.
|
||||
|
||||
Data Analysts, as ambassadors of this domain, employ these types, which are namely Descriptive Analytics, Diagnostic Analytics, Predictive Analytics and Prescriptive Analytics, to answer various questions — What happened? Why did it happen? What could happen? And what should we do next? Understanding these types gives data analysts the power to transform raw datasets into strategic insights.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Data Collection
|
||||
|
||||
In the realm of data analysis, the concept of collection holds immense importance. As the term suggests, collection refers to the process of gathering and measuring information on targeted variables in an established systematic fashion that enables a data analyst to answer relevant questions and evaluate outcomes. This step is foundational to any data analysis scheme, as it is the first line of interaction with the raw data that later transforms into viable insights. The effectiveness of data analysis is heavily reliant on the quality and quantity of data collected. Different methodologies and tools are employed for data collection depending on the nature of the data needed, such as surveys, observations, experiments, or scraping online data stores. This process should be carried out with clear objectives and careful consideration to ensure accuracy and relevance in the later stages of analysis and decision-making.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Cleanup
|
||||
|
||||
The Cleanup of Data is a critical component of a Data Analyst's role. It involves the process of inspecting, cleaning, transforming, and modeling data to discover useful information, inform conclusions, and support decision making. This process is crucial for Data Analysts to generate accurate and significant insights from data, ultimately resulting in better and more informed business decisions. A solid understanding of data cleanup procedures and techniques is a fundamental skill for any Data Analyst. Hence, it is necessary to hold a high emphasis on maintaining data quality by managing data integrity, accuracy, and consistency during the data cleanup process.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Exploration
|
||||
|
||||
In the realm of data analytics, exploration of data is a key concept that data analysts leverage to understand and interpret data effectively. Typically, this exploration process involves discerning patterns, identifying anomalies, examining underlying structures, and testing hypothesis, which often gets accomplished via descriptive statistics, visual methods, or sophisticated algorithms. It's a fundamental stepping-stone for any data analyst, ultimately guiding them in shaping the direction of further analysis or modeling. This concept serves as a foundation for dealing with complexities and uncertainties in data, hence improving decision-making in various fields ranging from business and finance to healthcare and social sciences.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Visualization - A Key Concept for Data Analysts
|
||||
|
||||
The visualization of data is an essential skill in the toolkit of every data analyst. This practice is about transforming complex raw data into a graphical format that allows for an easier understanding of large data sets, trends, outliers, and important patterns. Whether pie charts, line graphs, bar graphs, or heat maps, data visualization techniques not only streamline data analysis, but also facilitate a more effective communication of the findings to others. This key concept underscores the importance of presenting data in a digestible and visually appealing manner to drive data-informed decision making in an organization.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Statistical Analysis: A Key Concept for Data Analysts
|
||||
|
||||
Statistical analysis plays a critical role in the daily functions of a data analyst. It encompasses collecting, examining, interpreting, and present data, enabling data analysts to uncover patterns, trends and relationships, deduce insights and support decision-making in various fields. By applying statistical concepts, data analysts can transform complex data sets into understandable information that organizations can leverage for actionable insights. This cornerstone of data analysis enables analysts to deliver predictive models, trend analysis, and valuable business insights, making it indispensable in the world of data analytics. It is vital for data analysts to grasp such statistical methodologies to effectively decipher large data volumes they handle.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Machine Learning - A Key Concept for Data Analysts
|
||||
|
||||
Machine learning, a subset of artificial intelligence, is an indispensable tool in the hands of a data analyst. It provides the ability to automatically learn, improve from experience and make decisions without being explicitly programmed. In the context of a data analyst, machine learning contributes significantly in uncovering hidden insights, recognising patterns or making predictions based on large amounts of data. Through the use of varying algorithms and models, data analysts are able to leverage machine learning to convert raw data into meaningful information, making it a critical concept in data analysis.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Introduction to Key Concepts for Data
|
||||
|
||||
In the realm of data analysis, understanding some key concepts is essential. Data analysis is the process of inspecting, cleansing, transforming, and modeling data to discover useful information and support decision-making. In the broadest sense, data can be classified into various types like nominal, ordinal, interval and ratio, each with a specific role and analysis technique. Higher-dimensional data types like time-series, panel data, and multi-dimensional arrays are also critical. On the other hand, data quality and data management are key concepts to ensure clean and reliable datasets. With an understanding of these fundamental concepts, a data analyst can transform raw data into meaningful insights.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Introduction to Data Analysis
|
||||
|
||||
Data Analysis plays a crucial role in today's data-centric world. It involves the practice of inspecting, cleansing, transforming, and modeling data to extract valuable insights for decision-making. A **Data Analyst** is a professional primarily tasked with collecting, processing, and performing statistical analysis on large datasets. They discover how data can be used to answer questions and solve problems. With the rapid expansion of data in modern firms, the role of a data analyst has been evolving greatly, making them a significant asset in business strategy and decision-making processes.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user