mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-12 17:51:53 +08:00
Compare commits
48 Commits
fix/layout
...
fix/og
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e93e7cbe2 | ||
|
|
fd46f026dd | ||
|
|
41b179fc66 | ||
|
|
5f96fac3cc | ||
|
|
39fc4cb502 | ||
|
|
fac9a2bd6a | ||
|
|
2bc6d16f3f | ||
|
|
a77b0eefd0 | ||
|
|
dccbe683fd | ||
|
|
4db353e017 | ||
|
|
91dc6b862c | ||
|
|
d2efad25a8 | ||
|
|
4db5cc920b | ||
|
|
eef734abfd | ||
|
|
8e252cc062 | ||
|
|
abf33b8f47 | ||
|
|
e1a9dcc511 | ||
|
|
3cf246cc31 | ||
|
|
f9e90bfda5 | ||
|
|
d042f4511c | ||
|
|
b2adb619f0 | ||
|
|
3ab6942cee | ||
|
|
a20400f6a2 | ||
|
|
285f2c05f2 | ||
|
|
e716007245 | ||
|
|
c2a5e5a805 | ||
|
|
c4c7499a22 | ||
|
|
ad6002a514 | ||
|
|
b029eebd7b | ||
|
|
bacf0e6320 | ||
|
|
64ed4b6c23 | ||
|
|
5da5f41a8d | ||
|
|
ed6f4b64a6 | ||
|
|
c768cac62f | ||
|
|
3ad7765658 | ||
|
|
0e0a70fcbd | ||
|
|
5c330094f9 | ||
|
|
e9f5462180 | ||
|
|
c9534b30f5 | ||
|
|
8537698d91 | ||
|
|
9414089714 | ||
|
|
25965d91a0 | ||
|
|
7ce02300de | ||
|
|
f0859628f7 | ||
|
|
ff94ea7116 | ||
|
|
abad548961 | ||
|
|
6e81855645 | ||
|
|
60568caff7 |
105
.github/workflows/deployment.yml
vendored
105
.github/workflows/deployment.yml
vendored
@@ -1,41 +1,74 @@
|
||||
name: App Deployment
|
||||
name: Deploy to EC2
|
||||
on:
|
||||
workflow_dispatch: # allow manual run
|
||||
push:
|
||||
branches: [ master ]
|
||||
env:
|
||||
PUBLIC_API_URL: "https://api.roadmap.sh"
|
||||
PUBLIC_EDITOR_APP_URL: "https://draw.roadmap.sh"
|
||||
PUBLIC_AVATAR_BASE_URL: "https://dodrc8eu8m09s.cloudfront.net/avatars"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CI: true
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
build:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Prepare Draw Repository
|
||||
run: |
|
||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7.13.4
|
||||
- name: Setup Environment
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Generate meta and build
|
||||
run: |
|
||||
npm run generate-renderer
|
||||
npm run build
|
||||
touch ./dist/.nojekyll
|
||||
echo 'roadmap.sh' > ./dist/CNAME
|
||||
- name: Deploy to GH Pages
|
||||
run: |
|
||||
git config user.email "kamranahmed.se@gmail.com"
|
||||
git config user.name "Kamran Ahmed"
|
||||
git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git
|
||||
npm run deploy
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 20
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 8.15.6
|
||||
|
||||
# --------------------
|
||||
# Setup configuration
|
||||
# --------------------
|
||||
- name: Prepare configuration files
|
||||
run: |
|
||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/infra-config.git configuration --depth 1
|
||||
- name: Copy configuration files
|
||||
run: |
|
||||
cp configuration/dist/github/developer-roadmap.env .env
|
||||
|
||||
# --------------------
|
||||
# Prepare the build
|
||||
# --------------------
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Generate build
|
||||
run: |
|
||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
|
||||
npm run generate-renderer
|
||||
npm run build
|
||||
|
||||
# --------------------
|
||||
# Deploy to EC2
|
||||
# --------------------
|
||||
- uses: webfactory/ssh-agent@v0.7.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.EC2_PRIVATE_KEY }}
|
||||
- name: Deploy app to EC2
|
||||
run: |
|
||||
rsync -apvz --delete --no-times --exclude "configuration" -e "ssh -o StrictHostKeyChecking=no" -p ./ ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}:/var/www/roadmap.sh/
|
||||
- name: Restart PM2
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.EC2_HOST }}
|
||||
username: ${{ secrets.EC2_USERNAME }}
|
||||
key: ${{ secrets.EC2_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /var/www/roadmap.sh
|
||||
sudo pm2 restart web-roadmap
|
||||
|
||||
# --------------------
|
||||
# Clear Cloudfront Caching
|
||||
# --------------------
|
||||
- name: Clear Cloudfront Caching
|
||||
run: |
|
||||
curl -L \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/roadmapsh/infra-ansible/actions/workflows/playbook.yml/dispatches \
|
||||
-d '{ "ref":"master", "inputs": { "playbook": "roadmap_web.yml", "tags": "cloudfront", "is_verbose": false } }'
|
||||
@@ -1,10 +1,10 @@
|
||||
// https://astro.build/config
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import node from '@astrojs/node';
|
||||
import compress from 'astro-compress';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import rehypeExternalLinks from 'rehype-external-links';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
|
||||
|
||||
import react from '@astrojs/react';
|
||||
@@ -41,9 +41,11 @@ export default defineConfig({
|
||||
],
|
||||
],
|
||||
},
|
||||
build: {
|
||||
format: 'file',
|
||||
},
|
||||
output: 'hybrid',
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
trailingSlash: 'never',
|
||||
integrations: [
|
||||
tailwind({
|
||||
config: {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^8.2.1",
|
||||
"@astrojs/react": "^3.0.10",
|
||||
"@astrojs/sitemap": "^3.0.5",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
@@ -34,6 +35,7 @@
|
||||
"astro": "^4.4.0",
|
||||
"astro-compress": "^2.2.10",
|
||||
"clsx": "^2.1.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"dracula-prism": "^2.1.16",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -48,8 +50,10 @@
|
||||
"npm-check-updates": "^16.14.15",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-calendar-heatmap": "^1.9.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-tooltip": "^5.26.3",
|
||||
"reactflow": "^11.10.4",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
@@ -69,6 +73,7 @@
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react-calendar-heatmap": "^1.6.7",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^6.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
||||
359
pnpm-lock.yaml
generated
359
pnpm-lock.yaml
generated
@@ -5,9 +5,12 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
'@astrojs/node':
|
||||
specifier: ^8.2.1
|
||||
version: 8.2.1(astro@4.4.0)
|
||||
'@astrojs/react':
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3)
|
||||
version: 3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3)
|
||||
'@astrojs/sitemap':
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
@@ -22,10 +25,10 @@ dependencies:
|
||||
version: 0.7.1(nanostores@0.9.5)(react@18.2.0)
|
||||
'@resvg/resvg-js':
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
version: 2.6.2
|
||||
'@types/react':
|
||||
specifier: ^18.2.56
|
||||
version: 18.2.59
|
||||
version: 18.2.58
|
||||
'@types/react-dom':
|
||||
specifier: ^18.2.19
|
||||
version: 18.2.19
|
||||
@@ -38,6 +41,9 @@ dependencies:
|
||||
clsx:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
dayjs:
|
||||
specifier: ^1.11.10
|
||||
version: 1.11.10
|
||||
dom-to-image:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
@@ -80,15 +86,21 @@ dependencies:
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0
|
||||
react-calendar-heatmap:
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0(react@18.2.0)
|
||||
react-confetti:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(react@18.2.0)
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
react-tooltip:
|
||||
specifier: ^5.26.3
|
||||
version: 5.26.3(react-dom@18.2.0)(react@18.2.0)
|
||||
reactflow:
|
||||
specifier: ^11.10.4
|
||||
version: 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
rehype-external-links:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
@@ -121,7 +133,7 @@ dependencies:
|
||||
version: 11.0.4
|
||||
zustand:
|
||||
specifier: ^4.5.1
|
||||
version: 4.5.1(@types/react@18.2.59)(react@18.2.0)
|
||||
version: 4.5.1(@types/react@18.2.58)(react@18.2.0)
|
||||
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
@@ -139,6 +151,9 @@ devDependencies:
|
||||
'@types/prismjs':
|
||||
specifier: ^1.26.3
|
||||
version: 1.26.3
|
||||
'@types/react-calendar-heatmap':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
csv-parser:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
@@ -211,6 +226,18 @@ packages:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/@astrojs/node@8.2.1(astro@4.4.0):
|
||||
resolution: {integrity: sha512-n3VWx34V5te6g/Jm2rbpXzTdpCW86CmstaGbsPutOs6VaXvvWwk+ZibA/bFl7XgNpxqQ5d6Pqacnsn+xkZ/Kag==}
|
||||
peerDependencies:
|
||||
astro: ^4.2.0
|
||||
dependencies:
|
||||
astro: 4.4.0
|
||||
send: 0.18.0
|
||||
server-destroy: 1.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/@astrojs/prism@3.0.0:
|
||||
resolution: {integrity: sha512-g61lZupWq1bYbcBnYZqdjndShr/J3l/oFobBKPA3+qMat146zce3nz2kdO4giGbhYDt4gYdhmoBz0vZJ4sIurQ==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
@@ -218,7 +245,7 @@ packages:
|
||||
prismjs: 1.29.0
|
||||
dev: false
|
||||
|
||||
/@astrojs/react@3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3):
|
||||
/@astrojs/react@3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3):
|
||||
resolution: {integrity: sha512-uGRIwKMAn7tva2vxXMyoVIGxWFr0rjZ8ZWIlkTG/vIpnAjD2nM8Cz6B8j7yzj176jvl6gZ6xTbTVPm09aeK0Yw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
@@ -227,7 +254,7 @@ packages:
|
||||
react: ^17.0.2 || ^18.0.0
|
||||
react-dom: ^17.0.2 || ^18.0.0
|
||||
dependencies:
|
||||
'@types/react': 18.2.59
|
||||
'@types/react': 18.2.58
|
||||
'@types/react-dom': 18.2.19
|
||||
'@vitejs/plugin-react': 4.2.1(vite@5.1.3)
|
||||
react: 18.2.0
|
||||
@@ -757,6 +784,23 @@ packages:
|
||||
tslib: 2.6.2
|
||||
dev: false
|
||||
|
||||
/@floating-ui/core@1.6.0:
|
||||
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.1
|
||||
dev: false
|
||||
|
||||
/@floating-ui/dom@1.6.3:
|
||||
resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==}
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.6.0
|
||||
'@floating-ui/utils': 0.2.1
|
||||
dev: false
|
||||
|
||||
/@floating-ui/utils@0.2.1:
|
||||
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
|
||||
dev: false
|
||||
|
||||
/@gar/promisify@1.1.3:
|
||||
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
|
||||
dev: false
|
||||
@@ -1135,39 +1179,39 @@ packages:
|
||||
config-chain: 1.1.13
|
||||
dev: false
|
||||
|
||||
/@reactflow/background@11.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
|
||||
/@reactflow/background@11.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
classcat: 5.0.4
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@reactflow/controls@11.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
|
||||
/@reactflow/controls@11.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
classcat: 5.0.4
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@reactflow/core@11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
|
||||
/@reactflow/core@11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
@@ -1183,19 +1227,19 @@ packages:
|
||||
d3-zoom: 3.0.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@reactflow/minimap@11.7.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
|
||||
/@reactflow/minimap@11.7.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@types/d3-selection': 3.0.10
|
||||
'@types/d3-zoom': 3.0.8
|
||||
classcat: 5.0.4
|
||||
@@ -1203,48 +1247,48 @@ packages:
|
||||
d3-zoom: 3.0.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@reactflow/node-resizer@2.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
|
||||
/@reactflow/node-resizer@2.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
classcat: 5.0.4
|
||||
d3-drag: 3.0.0
|
||||
d3-selection: 3.0.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@reactflow/node-toolbar@1.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
|
||||
/@reactflow/node-toolbar@1.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
classcat: 5.0.4
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0)
|
||||
zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
dev: false
|
||||
|
||||
/@resvg/resvg-js-android-arm-eabi@2.6.0:
|
||||
resolution: {integrity: sha512-lJnZ/2P5aMocrFMW7HWhVne5gH82I8xH6zsfH75MYr4+/JOaVcGCTEQ06XFohGMdYRP3v05SSPLPvTM/RHjxfA==}
|
||||
/@resvg/resvg-js-android-arm-eabi@2.6.2:
|
||||
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
@@ -1252,8 +1296,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-android-arm64@2.6.0:
|
||||
resolution: {integrity: sha512-N527f529bjMwYWShZYfBD60dXA4Fux+D695QsHQ93BDYZSHUoOh1CUGUyICevnTxs7VgEl98XpArmUWBZQVMfQ==}
|
||||
/@resvg/resvg-js-android-arm64@2.6.2:
|
||||
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
@@ -1261,8 +1305,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-darwin-arm64@2.6.0:
|
||||
resolution: {integrity: sha512-MabUKLVayEwlPo0mIqAmMt+qESN8LltCvv5+GLgVga1avpUrkxj/fkU1TKm8kQegutUjbP/B0QuMuUr0uhF8ew==}
|
||||
/@resvg/resvg-js-darwin-arm64@2.6.2:
|
||||
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
@@ -1270,8 +1314,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-darwin-x64@2.6.0:
|
||||
resolution: {integrity: sha512-zrFetdnSw/suXjmyxSjfDV7i61hahv6DDG6kM7BYN2yJ3Es5+BZtqYZTcIWogPJedYKmzN1YTMWGd/3f0ubFiA==}
|
||||
/@resvg/resvg-js-darwin-x64@2.6.2:
|
||||
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
@@ -1279,8 +1323,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-linux-arm-gnueabihf@2.6.0:
|
||||
resolution: {integrity: sha512-sH4gxXt7v7dGwjGyzLwn7SFGvwZG6DQqLaZ11MmzbCwd9Zosy1TnmrMJfn6TJ7RHezmQMgBPi18bl55FZ1AT4A==}
|
||||
/@resvg/resvg-js-linux-arm-gnueabihf@2.6.2:
|
||||
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
@@ -1288,8 +1332,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-linux-arm64-gnu@2.6.0:
|
||||
resolution: {integrity: sha512-fCyMncqCJtrlANADIduYF4IfnWQ295UKib7DAxFXQhBsM9PLDTpizr0qemZcCNadcwSVHnAIzL4tliZhCM8P6A==}
|
||||
/@resvg/resvg-js-linux-arm64-gnu@2.6.2:
|
||||
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
@@ -1297,8 +1341,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-linux-arm64-musl@2.6.0:
|
||||
resolution: {integrity: sha512-ouLjTgBQHQyxLht4FdMPTvuY8xzJigM9EM2Tlu0llWkN1mKyTQrvYWi6TA6XnKdzDJHy7ZLpWpjZi7F5+Pg+Vg==}
|
||||
/@resvg/resvg-js-linux-arm64-musl@2.6.2:
|
||||
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
@@ -1306,8 +1350,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-linux-x64-gnu@2.6.0:
|
||||
resolution: {integrity: sha512-n3zC8DWsvxC1AwxpKFclIPapDFibs5XdIRoV/mcIlxlh0vseW1F49b97F33BtJQRmlntsqqN6GMMqx8byB7B+Q==}
|
||||
/@resvg/resvg-js-linux-x64-gnu@2.6.2:
|
||||
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
@@ -1315,8 +1359,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-linux-x64-musl@2.6.0:
|
||||
resolution: {integrity: sha512-n4tasK1HOlAxdTEROgYA1aCfsEKk0UOFDNd/AQTTZlTmCbHKXPq+O8npaaKlwXquxlVK8vrkcWbksbiGqbCAcw==}
|
||||
/@resvg/resvg-js-linux-x64-musl@2.6.2:
|
||||
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
@@ -1324,8 +1368,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-win32-arm64-msvc@2.6.0:
|
||||
resolution: {integrity: sha512-X2+EoBJFwDI5LDVb51Sk7ldnVLitMGr9WwU/i21i3fAeAXZb3hM16k67DeTy16OYkT2dk/RfU1tP1wG+rWbz2Q==}
|
||||
/@resvg/resvg-js-win32-arm64-msvc@2.6.2:
|
||||
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
@@ -1333,8 +1377,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-win32-ia32-msvc@2.6.0:
|
||||
resolution: {integrity: sha512-L7oevWjQoUgK5W1fCKn0euSVemhDXVhrjtwqpc7MwBKKimYeiOshO1Li1pa8bBt5PESahenhWgdB6lav9O0fEg==}
|
||||
/@resvg/resvg-js-win32-ia32-msvc@2.6.2:
|
||||
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
@@ -1342,8 +1386,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js-win32-x64-msvc@2.6.0:
|
||||
resolution: {integrity: sha512-8lJlghb+Unki5AyKgsnFbRJwkEj9r1NpwyuBG8yEJiG1W9eEGl03R3I7bsVa3haof/3J1NlWf0rzSa1G++A2iw==}
|
||||
/@resvg/resvg-js-win32-x64-msvc@2.6.2:
|
||||
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1351,22 +1395,22 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@resvg/resvg-js@2.6.0:
|
||||
resolution: {integrity: sha512-Tf3YpbBKcQn991KKcw/vg7vZf98v01seSv6CVxZBbRkL/xyjnoYB6KgrFL6zskT1A4dWC/vg77KyNOW+ePaNlA==}
|
||||
/@resvg/resvg-js@2.6.2:
|
||||
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
|
||||
engines: {node: '>= 10'}
|
||||
optionalDependencies:
|
||||
'@resvg/resvg-js-android-arm-eabi': 2.6.0
|
||||
'@resvg/resvg-js-android-arm64': 2.6.0
|
||||
'@resvg/resvg-js-darwin-arm64': 2.6.0
|
||||
'@resvg/resvg-js-darwin-x64': 2.6.0
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.0
|
||||
'@resvg/resvg-js-linux-arm64-gnu': 2.6.0
|
||||
'@resvg/resvg-js-linux-arm64-musl': 2.6.0
|
||||
'@resvg/resvg-js-linux-x64-gnu': 2.6.0
|
||||
'@resvg/resvg-js-linux-x64-musl': 2.6.0
|
||||
'@resvg/resvg-js-win32-arm64-msvc': 2.6.0
|
||||
'@resvg/resvg-js-win32-ia32-msvc': 2.6.0
|
||||
'@resvg/resvg-js-win32-x64-msvc': 2.6.0
|
||||
'@resvg/resvg-js-android-arm-eabi': 2.6.2
|
||||
'@resvg/resvg-js-android-arm64': 2.6.2
|
||||
'@resvg/resvg-js-darwin-arm64': 2.6.2
|
||||
'@resvg/resvg-js-darwin-x64': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm64-gnu': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm64-musl': 2.6.2
|
||||
'@resvg/resvg-js-linux-x64-gnu': 2.6.2
|
||||
'@resvg/resvg-js-linux-x64-musl': 2.6.2
|
||||
'@resvg/resvg-js-win32-arm64-msvc': 2.6.2
|
||||
'@resvg/resvg-js-win32-ia32-msvc': 2.6.2
|
||||
'@resvg/resvg-js-win32-x64-msvc': 2.6.2
|
||||
dev: false
|
||||
|
||||
/@rollup/rollup-android-arm-eabi@4.9.6:
|
||||
@@ -1867,21 +1911,25 @@ packages:
|
||||
|
||||
/@types/prop-types@15.7.11:
|
||||
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
|
||||
dev: false
|
||||
|
||||
/@types/react-calendar-heatmap@1.6.7:
|
||||
resolution: {integrity: sha512-xWBS9iOvw+aCidPk8QwCH69OCO7jnj6/9TjooqGQ9W+rA5m1aw36GjQMlSYKAg86otDeg9dzA+hSAIcvw/y9Rg==}
|
||||
dependencies:
|
||||
'@types/react': 18.2.58
|
||||
dev: true
|
||||
|
||||
/@types/react-dom@18.2.19:
|
||||
resolution: {integrity: sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==}
|
||||
dependencies:
|
||||
'@types/react': 18.2.59
|
||||
'@types/react': 18.2.58
|
||||
dev: false
|
||||
|
||||
/@types/react@18.2.59:
|
||||
resolution: {integrity: sha512-DE+F6BYEC8VtajY85Qr7mmhTd/79rJKIHCg99MU9SWPB4xvLb6D1za2vYflgZfmPqQVEr6UqJTnLXEwzpVPuOg==}
|
||||
/@types/react@18.2.58:
|
||||
resolution: {integrity: sha512-TaGvMNhxvG2Q0K0aYxiKfNDS5m5ZsoIBBbtfUorxdH4NGSXIlYvZxLJI+9Dd3KjeB3780bciLyAb7ylO8pLhPw==}
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.11
|
||||
'@types/scheduler': 0.16.8
|
||||
csstype: 3.1.3
|
||||
dev: false
|
||||
|
||||
/@types/sax@1.2.7:
|
||||
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
|
||||
@@ -1891,7 +1939,6 @@ packages:
|
||||
|
||||
/@types/scheduler@0.16.8:
|
||||
resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==}
|
||||
dev: false
|
||||
|
||||
/@types/unist@2.0.10:
|
||||
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
|
||||
@@ -2465,6 +2512,10 @@ packages:
|
||||
resolution: {integrity: sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g==}
|
||||
dev: false
|
||||
|
||||
/classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
dev: false
|
||||
|
||||
/clean-css@5.3.3:
|
||||
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
|
||||
engines: {node: '>= 10.0'}
|
||||
@@ -2529,7 +2580,6 @@ packages:
|
||||
|
||||
/color-string@1.9.1:
|
||||
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
simple-swizzle: 0.2.2
|
||||
@@ -2709,7 +2759,6 @@ packages:
|
||||
|
||||
/csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
dev: false
|
||||
|
||||
/csv-parser@3.0.0:
|
||||
resolution: {integrity: sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ==}
|
||||
@@ -2784,6 +2833,21 @@ packages:
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
dev: false
|
||||
|
||||
/dayjs@1.11.10:
|
||||
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
|
||||
dev: false
|
||||
|
||||
/debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
dev: false
|
||||
|
||||
/debug@4.3.4:
|
||||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -2834,11 +2898,21 @@ packages:
|
||||
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
|
||||
dev: false
|
||||
|
||||
/depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/destroy@1.2.0:
|
||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
dev: false
|
||||
|
||||
/detect-libc@1.0.3:
|
||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||
engines: {node: '>=0.10'}
|
||||
@@ -2949,6 +3023,10 @@ packages:
|
||||
/eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
/ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
dev: false
|
||||
|
||||
/electron-to-chromium@1.4.640:
|
||||
resolution: {integrity: sha512-z/6oZ/Muqk4BaE7P69bXhUhpJbUM9ZJeka43ZwxsDshKtePns4mhBlh8bU5+yrnOnz3fhG82XLzGUXazOmsWnA==}
|
||||
dev: false
|
||||
@@ -2967,6 +3045,11 @@ packages:
|
||||
/emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
/encodeurl@1.0.2:
|
||||
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/encoding@0.1.13:
|
||||
resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
|
||||
requiresBuild: true
|
||||
@@ -3066,6 +3149,11 @@ packages:
|
||||
'@types/estree': 1.0.5
|
||||
dev: false
|
||||
|
||||
/etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3247,6 +3335,11 @@ packages:
|
||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||
dev: false
|
||||
|
||||
/fresh@0.5.2:
|
||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/fs-constants@1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
requiresBuild: true
|
||||
@@ -3646,6 +3739,17 @@ packages:
|
||||
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
|
||||
dev: false
|
||||
|
||||
/http-errors@2.0.0:
|
||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.1
|
||||
toidentifier: 1.0.1
|
||||
dev: false
|
||||
|
||||
/http-proxy-agent@5.0.0:
|
||||
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -3775,7 +3879,6 @@ packages:
|
||||
|
||||
/is-arrayish@0.3.2:
|
||||
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
|
||||
/is-binary-path@2.1.0:
|
||||
@@ -4486,6 +4589,10 @@ packages:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
dev: true
|
||||
|
||||
/memoize-one@5.2.1:
|
||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||
dev: false
|
||||
|
||||
/merge-stream@2.0.0:
|
||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
||||
dev: false
|
||||
@@ -4760,6 +4867,12 @@ packages:
|
||||
mime-db: 1.52.0
|
||||
dev: true
|
||||
|
||||
/mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/mime@3.0.0:
|
||||
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -4901,6 +5014,10 @@ packages:
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
dev: false
|
||||
|
||||
/ms@2.1.2:
|
||||
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
||||
dev: false
|
||||
@@ -5185,6 +5302,13 @@ packages:
|
||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
/on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
dev: false
|
||||
|
||||
/once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
dependencies:
|
||||
@@ -5698,6 +5822,14 @@ packages:
|
||||
sisteransi: 1.0.5
|
||||
dev: false
|
||||
|
||||
/prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
dev: false
|
||||
|
||||
/property-information@6.4.0:
|
||||
resolution: {integrity: sha512-9t5qARVofg2xQqKtytzt+lZ4d1Qvj8t5B8fEwXK6qOfgRLgH/b13QlgEyDh033NOS31nXeFbYv7CLUDG1CeifQ==}
|
||||
dev: false
|
||||
@@ -5747,6 +5879,11 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/rc-config-loader@4.1.3:
|
||||
resolution: {integrity: sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==}
|
||||
dependencies:
|
||||
@@ -5768,6 +5905,16 @@ packages:
|
||||
strip-json-comments: 2.0.1
|
||||
dev: false
|
||||
|
||||
/react-calendar-heatmap@1.9.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-mGed9any6QLOVckxwxC/eeP9s9wE8mTUW/FCE0V27xF9WOaCGuOftGSRH8DSDoSwgzMSVF6uuH7M1xvc+aZ8sg==}
|
||||
peerDependencies:
|
||||
react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
memoize-one: 5.2.1
|
||||
prop-types: 15.8.1
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/react-confetti@6.1.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==}
|
||||
engines: {node: '>=10.18'}
|
||||
@@ -5788,11 +5935,27 @@ packages:
|
||||
scheduler: 0.23.0
|
||||
dev: false
|
||||
|
||||
/react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
dev: false
|
||||
|
||||
/react-refresh@0.14.0:
|
||||
resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/react-tooltip@5.26.3(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-MpYAws8CEHUd/RC4GaDCdoceph/T4KHM5vS5Dbk8FOmLMvvIht2ymP2htWdrke7K6lqPO8rz8+bnwWUIXeDlzg==}
|
||||
peerDependencies:
|
||||
react: '>=16.14.0'
|
||||
react-dom: '>=16.14.0'
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.3
|
||||
classnames: 2.5.1
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react@18.2.0:
|
||||
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5800,18 +5963,18 @@ packages:
|
||||
loose-envify: 1.4.0
|
||||
dev: false
|
||||
|
||||
/reactflow@11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0):
|
||||
/reactflow@11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
dependencies:
|
||||
'@reactflow/background': 11.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/controls': 11.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/minimap': 11.7.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/node-resizer': 2.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/node-toolbar': 1.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/background': 11.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/controls': 11.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/minimap': 11.7.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/node-resizer': 2.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@reactflow/node-toolbar': 1.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
@@ -6186,6 +6349,27 @@ packages:
|
||||
lru-cache: 6.0.0
|
||||
dev: false
|
||||
|
||||
/send@0.18.0:
|
||||
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
depd: 2.0.0
|
||||
destroy: 1.2.0
|
||||
encodeurl: 1.0.2
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
fresh: 0.5.2
|
||||
http-errors: 2.0.0
|
||||
mime: 1.6.0
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.2.1
|
||||
statuses: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/server-destroy@1.0.1:
|
||||
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
|
||||
dev: false
|
||||
@@ -6194,6 +6378,10 @@ packages:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
dev: false
|
||||
|
||||
/setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
dev: false
|
||||
|
||||
/sharp@0.32.6:
|
||||
resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==}
|
||||
engines: {node: '>=14.15.0'}
|
||||
@@ -6300,7 +6488,6 @@ packages:
|
||||
|
||||
/simple-swizzle@0.2.2:
|
||||
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
is-arrayish: 0.3.2
|
||||
dev: false
|
||||
@@ -6421,6 +6608,11 @@ packages:
|
||||
minipass: 3.3.6
|
||||
dev: false
|
||||
|
||||
/statuses@2.0.1:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/stdin-discarder@0.1.0:
|
||||
resolution: {integrity: sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -6706,6 +6898,11 @@ packages:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
/toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
dev: false
|
||||
|
||||
/tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
dev: true
|
||||
@@ -7228,7 +7425,7 @@ packages:
|
||||
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
|
||||
dev: false
|
||||
|
||||
/zustand@4.5.1(@types/react@18.2.59)(react@18.2.0):
|
||||
/zustand@4.5.1(@types/react@18.2.58)(react@18.2.0):
|
||||
resolution: {integrity: sha512-XlauQmH64xXSC1qGYNv00ODaQ3B+tNPoy22jv2diYiP4eoDKr9LA+Bh5Bc3gplTrFdb6JVI+N4kc1DZ/tbtfPg==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
@@ -7243,7 +7440,7 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.2.59
|
||||
'@types/react': 18.2.58
|
||||
react: 18.2.0
|
||||
use-sync-external-store: 1.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
@@ -29,4 +29,4 @@ done
|
||||
|
||||
|
||||
# ignore the worktree changes for the editor directory
|
||||
git update-index --assume-unchanged editor/readonly-editor.tsx
|
||||
git update-index --assume-unchanged editor/readonly-editor.tsx || true
|
||||
153
src/api/api.ts
Normal file
153
src/api/api.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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
Normal file
124
src/api/user.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { type APIContext } from 'astro';
|
||||
import { api } from './api.ts';
|
||||
import type { ResourceType } from '../lib/resource-progress.ts';
|
||||
|
||||
export const allowedRoadmapVisibility = ['all', 'none', 'selected'] as const;
|
||||
export type AllowedRoadmapVisibility =
|
||||
(typeof allowedRoadmapVisibility)[number];
|
||||
|
||||
export const allowedCustomRoadmapVisibility = [
|
||||
'all',
|
||||
'none',
|
||||
'selected',
|
||||
] as const;
|
||||
export type AllowedCustomRoadmapVisibility =
|
||||
(typeof allowedCustomRoadmapVisibility)[number];
|
||||
|
||||
export const allowedProfileVisibility = ['public', 'private'] as const;
|
||||
export type AllowedProfileVisibility =
|
||||
(typeof allowedProfileVisibility)[number];
|
||||
|
||||
export interface UserDocument {
|
||||
_id?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
password: string;
|
||||
isEnabled: boolean;
|
||||
authProvider: 'github' | 'google' | 'email' | 'linkedin';
|
||||
metadata: Record<string, any>;
|
||||
calculatedStats: {
|
||||
activityCount: number;
|
||||
totalVisitCount: number;
|
||||
longestVisitStreak: number;
|
||||
currentVisitStreak: number;
|
||||
updatedAt: Date;
|
||||
};
|
||||
verificationCode: string;
|
||||
resetPasswordCode: string;
|
||||
isSyncedWithSendy: boolean;
|
||||
links?: {
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
twitter?: string;
|
||||
website?: string;
|
||||
};
|
||||
username?: string;
|
||||
profileVisibility: AllowedProfileVisibility;
|
||||
publicConfig?: {
|
||||
isAvailableForHire: boolean;
|
||||
isEmailVisible: boolean;
|
||||
headline: string;
|
||||
roadmaps: string[];
|
||||
customRoadmaps: string[];
|
||||
roadmapVisibility: AllowedRoadmapVisibility;
|
||||
customRoadmapVisibility: AllowedCustomRoadmapVisibility;
|
||||
};
|
||||
resetPasswordCodeAt: string;
|
||||
verifiedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type UserActivityCount = {
|
||||
activityCount: Record<string, number>;
|
||||
totalActivityCount: number;
|
||||
};
|
||||
|
||||
type ProgressResponse = {
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
done: number;
|
||||
total: number;
|
||||
isCustomResource?: boolean;
|
||||
roadmapSlug?: string;
|
||||
};
|
||||
|
||||
export type GetPublicProfileResponse = Omit<
|
||||
UserDocument,
|
||||
'password' | 'verificationCode' | 'resetPasswordCode' | 'resetPasswordCodeAt'
|
||||
> & {
|
||||
activity: UserActivityCount;
|
||||
roadmaps: ProgressResponse[];
|
||||
isOwnProfile: boolean;
|
||||
};
|
||||
|
||||
export type GetUserProfileRoadmapResponse = {
|
||||
title: string;
|
||||
topicCount: number;
|
||||
roadmapSlug?: string;
|
||||
isCustomResource?: boolean;
|
||||
done: string[];
|
||||
learning: string[];
|
||||
skipped: string[];
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
};
|
||||
|
||||
export function userApi(context: APIContext) {
|
||||
return {
|
||||
getPublicProfile: async function (username: string) {
|
||||
return api(context).get<GetPublicProfileResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-public-profile/${username}`,
|
||||
);
|
||||
},
|
||||
getUserProfileRoadmap: async function (
|
||||
username: string,
|
||||
resourceId: string,
|
||||
resourceType: ResourceType = 'roadmap',
|
||||
) {
|
||||
return api(context).get<GetUserProfileRoadmapResponse>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-user-profile-roadmap/${username}`,
|
||||
{
|
||||
resourceId,
|
||||
resourceType,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,16 @@ const sidebarLinks = [
|
||||
classes: 'h-3 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/update-profile',
|
||||
title: 'Profile',
|
||||
id: 'profile',
|
||||
isNew: true,
|
||||
icon: {
|
||||
glyph: 'user',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/friends',
|
||||
title: 'Friends',
|
||||
@@ -37,7 +47,7 @@ const sidebarLinks = [
|
||||
href: '/account/roadmaps',
|
||||
title: 'Roadmaps',
|
||||
id: 'roadmaps',
|
||||
isNew: true,
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'users',
|
||||
classes: 'h-4 w-4',
|
||||
@@ -54,16 +64,6 @@ const sidebarLinks = [
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/update-profile',
|
||||
title: 'Profile',
|
||||
id: 'profile',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'user',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/settings',
|
||||
title: 'Settings',
|
||||
|
||||
@@ -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-10 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-[1.62rem] items-center gap-2 sm:gap-0 justify-end">
|
||||
<h2 className="text-base sm:text-5xl font-bold">
|
||||
{count}
|
||||
</h2>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -14,6 +15,7 @@ type ProgressResponse = {
|
||||
done: number;
|
||||
total: number;
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
};
|
||||
|
||||
export type ActivityResponse = {
|
||||
@@ -44,6 +46,7 @@ export type ActivityResponse = {
|
||||
resourceTitle?: string;
|
||||
};
|
||||
}[];
|
||||
activities: UserStreamActivity[];
|
||||
};
|
||||
|
||||
export function ActivityPage() {
|
||||
@@ -95,8 +98,13 @@ 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 (
|
||||
<>
|
||||
@@ -106,16 +114,17 @@ export function ActivityPage() {
|
||||
streak={activity?.streak || { count: 0 }}
|
||||
/>
|
||||
|
||||
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
<div className="mx-0 px-0 py-5 pb-0 md:-mx-10 md:px-8 md:py-8 md:pb-0">
|
||||
{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="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{learningRoadmaps
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
@@ -191,6 +200,10 @@ export function ActivityPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasProgress && (
|
||||
<ActivityStream activities={activity?.activities || []} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
163
src/components/Activity/ActivityStream.tsx
Normal file
163
src/components/Activity/ActivityStream.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
136
src/components/Activity/ActivityTopicsModal.tsx
Normal file
136
src/components/Activity/ActivityTopicsModal.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
31
src/components/Activity/EmptyStream.tsx
Normal file
31
src/components/Activity/EmptyStream.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
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,9 +1,6 @@
|
||||
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';
|
||||
@@ -17,13 +14,11 @@ 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;
|
||||
|
||||
@@ -37,134 +32,50 @@ 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?id=${resourceId}`;
|
||||
url = `/r/${roadmapSlug}`;
|
||||
}
|
||||
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
|
||||
const progressPercentage = getPercentage(totalMarked, totalCount);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<a
|
||||
target="_blank"
|
||||
href={url}
|
||||
className="group relative flex cursor-pointer items-center rounded-t-md border p-3 text-gray-600 hover:border-gray-300 hover:text-black"
|
||||
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"
|
||||
>
|
||||
<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 group-hover:bg-black/10`}
|
||||
className="absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 transition-colors 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>
|
||||
|
||||
{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 className="absolute right-2 top-0 flex h-full items-center">
|
||||
<ResourceProgressActions
|
||||
userId={userId!}
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
onCleared={onCleared}
|
||||
showClearButton={showClearButton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
132
src/components/Activity/ResourceProgressActions.tsx
Normal file
132
src/components/Activity/ResourceProgressActions.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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,6 +14,7 @@ import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export type TeamResourceConfig = {
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
visibility?: AllowedRoadmapVisibility;
|
||||
@@ -80,7 +81,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -114,7 +115,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -312,7 +313,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
`${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${resourceId}`,
|
||||
'_blank'
|
||||
'_blank',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -335,7 +336,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function CreateVersion(props: CreateVersionProps) {
|
||||
return (
|
||||
<div className={'flex items-center'}>
|
||||
<a
|
||||
href={`/r?id=${userVersion._id}`}
|
||||
href={`/r/${userVersion?.slug}`}
|
||||
className="flex items-center rounded-md border border-blue-400 bg-gray-50 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:hover:bg-gray-100 max-sm:hidden sm:text-sm"
|
||||
>
|
||||
<Map size="15px" className="mr-1.5" />
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface RoadmapDocument {
|
||||
_id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
creatorId: string;
|
||||
teamId?: string;
|
||||
isDiscoverable: boolean;
|
||||
@@ -145,7 +146,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
name="title"
|
||||
id="title"
|
||||
required
|
||||
className="block text-black w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
|
||||
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 text-black outline-none focus:border-black sm:text-sm"
|
||||
placeholder="Enter Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -165,8 +166,8 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
name="description"
|
||||
required
|
||||
className={cn(
|
||||
'block text-black h-24 w-full resize-none rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm',
|
||||
isInvalidDescription && 'border-red-300 bg-red-100'
|
||||
'block h-24 w-full resize-none rounded-md border border-gray-300 px-2.5 py-2 text-black outline-none focus:border-black sm:text-sm',
|
||||
isInvalidDescription && 'border-red-300 bg-red-100',
|
||||
)}
|
||||
placeholder="Enter Description"
|
||||
value={description}
|
||||
|
||||
@@ -56,10 +56,11 @@ export function hideRoadmapLoader() {
|
||||
|
||||
type CustomRoadmapProps = {
|
||||
isEmbed?: boolean;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export function CustomRoadmap(props: CustomRoadmapProps) {
|
||||
const { isEmbed = false } = props;
|
||||
const { isEmbed = false, slug } = props;
|
||||
|
||||
const { id, secret } = getUrlParams() as { id: string; secret: string };
|
||||
|
||||
@@ -70,9 +71,11 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
|
||||
async function getRoadmap() {
|
||||
setIsLoading(true);
|
||||
|
||||
const roadmapUrl = new URL(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`,
|
||||
);
|
||||
const roadmapUrl = slug
|
||||
? new URL(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap-by-slug/${slug}`,
|
||||
)
|
||||
: new URL(`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`);
|
||||
|
||||
if (secret) {
|
||||
roadmapUrl.searchParams.set('secret', secret);
|
||||
@@ -113,7 +116,12 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
|
||||
<>
|
||||
{!isEmbed && <RoadmapHeader />}
|
||||
<FlowRoadmapRenderer isEmbed={isEmbed} roadmap={roadmap!} />
|
||||
<TopicDetail isEmbed={isEmbed} canSubmitContribution={false} />
|
||||
<TopicDetail
|
||||
resourceTitle={roadmap!.title}
|
||||
resourceType="roadmap"
|
||||
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,6 +61,7 @@ export function PersonalRoadmapList(props: PersonalRoadmapListType) {
|
||||
|
||||
const shareSettingsModal = selectedRoadmap && (
|
||||
<ShareOptionsModal
|
||||
roadmapSlug={selectedRoadmap?.slug}
|
||||
isDiscoverable={selectedRoadmap.isDiscoverable}
|
||||
description={selectedRoadmap.description}
|
||||
visibility={selectedRoadmap.visibility}
|
||||
@@ -129,7 +130,7 @@ type CustomRoadmapItemProps = {
|
||||
roadmap: GetRoadmapListResponse['personalRoadmaps'][number];
|
||||
onRemove: (roadmapId: string) => Promise<void>;
|
||||
setSelectedRoadmap: (
|
||||
roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null
|
||||
roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null,
|
||||
) => void;
|
||||
};
|
||||
|
||||
@@ -183,9 +184,9 @@ function CustomRoadmapItem(props: CustomRoadmapItemProps) {
|
||||
Edit
|
||||
</a>
|
||||
<a
|
||||
href={`/r?id=${roadmap._id}`}
|
||||
href={`/r/${roadmap?.slug}`}
|
||||
className={
|
||||
'ml-2 flex items-center gap-2 rounded-md border border-blue-400 bg-white px-2 py-1.5 text-xs hover:bg-blue-50 focus:outline-none text-blue-600'
|
||||
'ml-2 flex items-center gap-2 rounded-md border border-blue-400 bg-white px-2 py-1.5 text-xs text-blue-600 hover:bg-blue-50 focus:outline-none'
|
||||
}
|
||||
target={'_blank'}
|
||||
>
|
||||
|
||||
@@ -24,6 +24,7 @@ export function ResourceProgressStats(props: ResourceProgressStatsProps) {
|
||||
<>
|
||||
{isSharing && $canManageCurrentRoadmap && $currentRoadmap && (
|
||||
<ShareOptionsModal
|
||||
roadmapSlug={$currentRoadmap?.slug}
|
||||
isDiscoverable={$currentRoadmap.isDiscoverable}
|
||||
description={$currentRoadmap?.description}
|
||||
visibility={$currentRoadmap?.visibility}
|
||||
@@ -47,7 +48,7 @@ export function ResourceProgressStats(props: ResourceProgressStatsProps) {
|
||||
{
|
||||
'rounded-bl-md rounded-br-md': isSecondaryBanner,
|
||||
'rounded-md': !isSecondaryBanner,
|
||||
}
|
||||
},
|
||||
)}
|
||||
>
|
||||
<p
|
||||
|
||||
@@ -24,6 +24,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
title,
|
||||
description,
|
||||
_id: roadmapId,
|
||||
slug: roadmapSlug,
|
||||
creator,
|
||||
team,
|
||||
visibility,
|
||||
@@ -79,6 +80,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
>
|
||||
<ShareSuccess
|
||||
visibility="public"
|
||||
roadmapSlug={roadmapSlug}
|
||||
roadmapId={roadmapId!}
|
||||
description={description}
|
||||
onClose={() => setIsSharingWithOthers(false)}
|
||||
@@ -135,7 +137,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
<ShareRoadmapButton
|
||||
roadmapId={roadmapId!}
|
||||
description={description!}
|
||||
pageUrl={`https://roadmap.sh/r?id=${roadmapId}`}
|
||||
pageUrl={`https://roadmap.sh/r/${roadmapSlug}`}
|
||||
allowEmbed={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -144,6 +146,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
<>
|
||||
{isSharing && $currentRoadmap && (
|
||||
<ShareOptionsModal
|
||||
roadmapSlug={$currentRoadmap?.slug}
|
||||
isDiscoverable={$currentRoadmap.isDiscoverable}
|
||||
description={$currentRoadmap?.description}
|
||||
visibility={$currentRoadmap?.visibility}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function SharedRoadmapList(props: SharedRoadmapListProps) {
|
||||
className="relative flex w-full border-t"
|
||||
>
|
||||
<a
|
||||
href={`/r?id=${roadmap._id}`}
|
||||
href={`/r/=${roadmap?.slug}`}
|
||||
className="group inline-grid w-full grid-cols-[auto,16px] items-center justify-between gap-2 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-black"
|
||||
target={'_blank'}
|
||||
>
|
||||
|
||||
@@ -11,7 +11,7 @@ const { heading, guides } = Astro.props;
|
||||
---
|
||||
|
||||
<div class='container'>
|
||||
<h1 class='text-2xl sm:text-3xl font-bold block'>{heading}</h1>
|
||||
<h2 class='text-2xl sm:text-3xl font-bold block'>{heading}</h2>
|
||||
|
||||
<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'>
|
||||
<h1 class='text-2xl sm:text-3xl font-bold block'>{heading}</h1>
|
||||
<h2 class='text-2xl sm:text-3xl font-bold block'>{heading}</h2>
|
||||
|
||||
<div class='mt-3 sm:my-5'>
|
||||
{videos.map((video) => <VideoListItem video={video} />)}
|
||||
|
||||
@@ -69,6 +69,10 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (trimmedValue.length < 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (termCache.has(trimmedValue)) {
|
||||
const cachedData = termCache.get(trimmedValue);
|
||||
return cachedData || [];
|
||||
@@ -246,7 +250,7 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
|
||||
key={result?._id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center rounded p-2 text-sm',
|
||||
'flex w-full items-start rounded p-2 text-left text-sm',
|
||||
counter === activeCounter ? 'bg-gray-100' : '',
|
||||
)}
|
||||
onMouseOver={() => setActiveCounter(counter)}
|
||||
@@ -264,7 +268,7 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mr-2 rounded-full p-1 px-1.5 text-xs leading-none',
|
||||
'mr-2 whitespace-nowrap 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,7 +113,9 @@ 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();
|
||||
|
||||
@@ -658,7 +660,7 @@ export function GenerateRoadmap() {
|
||||
</div>
|
||||
<div
|
||||
className={cn({
|
||||
'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':
|
||||
'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]':
|
||||
!isAuthenticatedUser,
|
||||
})}
|
||||
>
|
||||
@@ -666,18 +668,18 @@ export function GenerateRoadmap() {
|
||||
ref={roadmapContainerRef}
|
||||
id="roadmap-container"
|
||||
onClick={handleNodeClick}
|
||||
className="relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
|
||||
className="relative min-h-[400px] 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 px-5 max-w-[600px] flex-col items-center justify-center bg-gray-100 pt-px">
|
||||
<div className="mx-auto max-w-[600px] flex-col items-center justify-center bg-gray-100 px-5 pt-px">
|
||||
<div className="mt-8 text-center">
|
||||
<h2 className="mb-0.5 sm:mb-3 text-xl sm:text-2xl font-medium">
|
||||
<h2 className="mb-0.5 text-xl font-medium sm:mb-3 sm:text-2xl">
|
||||
Sign up to View the full roadmap
|
||||
</h2>
|
||||
<p className="mb-6 text-sm sm:text-base text-gray-600 text-balance">
|
||||
<p className="mb-6 text-balance text-sm text-gray-600 sm:text-base">
|
||||
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">
|
||||
<div className="flex flex-grow flex-col items-center px-4 py-6 sm:px-6 md:my-24 lg:my-32">
|
||||
{isConfiguring && (
|
||||
<IncreaseRoadmapLimit
|
||||
onClose={() => {
|
||||
@@ -55,7 +55,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-0 text-center sm:gap-2 md:mt-24 lg:mt-32">
|
||||
<div className="flex flex-col gap-0 text-center sm:gap-2">
|
||||
<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>
|
||||
|
||||
61
src/components/Guide/GuideContent.astro
Normal file
61
src/components/Guide/GuideContent.astro
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
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,6 +7,8 @@ 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,6 +16,7 @@ export type UserProgressResponse = {
|
||||
total: number;
|
||||
updatedAt: Date;
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
team?: {
|
||||
name: string;
|
||||
id: string;
|
||||
@@ -41,7 +42,7 @@ function renderProgress(progressList: UserProgressResponse) {
|
||||
resourceType: progress.resourceType,
|
||||
isFavorite: progress.isFavorite,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const totalDone = progress.done + progress.skipped;
|
||||
@@ -89,7 +90,7 @@ export function FavoriteRoadmaps() {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response: progressList, error } = await httpGet<ProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`,
|
||||
);
|
||||
|
||||
if (error || !progressList) {
|
||||
@@ -121,7 +122,7 @@ export function FavoriteRoadmaps() {
|
||||
|
||||
const hasProgress = progress?.length > 0;
|
||||
const customRoadmaps = progress?.filter(
|
||||
(p) => p.isCustomResource && !p.team?.name
|
||||
(p) => p.isCustomResource && !p.team?.name,
|
||||
);
|
||||
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
|
||||
const teamRoadmaps: HeroTeamRoadmaps = progress
|
||||
|
||||
@@ -172,7 +172,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
||||
customRoadmap.total) *
|
||||
100
|
||||
}
|
||||
url={`/r?id=${customRoadmap.resourceId}`}
|
||||
url={`/r/${customRoadmap?.roadmapSlug}`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
);
|
||||
@@ -242,7 +242,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
||||
customRoadmap.total) *
|
||||
100
|
||||
}
|
||||
url={`/r?id=${customRoadmap.resourceId}`}
|
||||
url={`/r/${customRoadmap?.roadmapSlug}`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
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'
|
||||
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'
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { ChevronRight, LogOut, Map, Plus, User2, Users2 } from 'lucide-react';
|
||||
import {
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Map,
|
||||
Plus,
|
||||
SquareUserRound,
|
||||
User2,
|
||||
Users2,
|
||||
} from 'lucide-react';
|
||||
import { logout } from './navigation';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
import { useState } from 'react';
|
||||
@@ -10,7 +18,6 @@ type AccountDropdownListProps = {
|
||||
|
||||
export function AccountDropdownList(props: AccountDropdownListProps) {
|
||||
const { setIsTeamsOpen, onCreateRoadmap } = props;
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
return (
|
||||
<ul>
|
||||
@@ -20,7 +27,21 @@ export function AccountDropdownList(props: AccountDropdownListProps) {
|
||||
className="group flex items-center gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<User2 className="h-4 w-4 stroke-[2.5px] text-slate-400 group-hover:text-white" />
|
||||
Profile
|
||||
Account
|
||||
</a>
|
||||
</li>
|
||||
<li className="px-1">
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
<li className="px-1">
|
||||
@@ -66,7 +87,7 @@ export function AccountDropdownList(props: AccountDropdownListProps) {
|
||||
</li>
|
||||
<li className="px-1">
|
||||
<button
|
||||
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"
|
||||
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"
|
||||
type="button"
|
||||
onClick={logout}
|
||||
>
|
||||
|
||||
@@ -17,12 +17,10 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
</a>
|
||||
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer nofollow'
|
||||
href='https://boards.greenhouse.io/insightmediagroupllc/jobs/4002116008'
|
||||
href='/ai'
|
||||
class='group inline sm:hidden relative !mr-2 text-blue-300 hover:text-white'
|
||||
>
|
||||
We're Hiring
|
||||
AI Roadmaps
|
||||
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
@@ -43,12 +41,10 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
</a>
|
||||
<a href='/teams' class='text-gray-400 hover:text-white'> Teams</a>
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer nofollow'
|
||||
href='https://boards.greenhouse.io/insightmediagroupllc/jobs/4002116008'
|
||||
href='/ai'
|
||||
class='group relative !mr-2 text-blue-300 hover:text-white'
|
||||
>
|
||||
We're Hiring
|
||||
AI Roadmaps
|
||||
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
|
||||
@@ -68,12 +68,13 @@ export function NavigationDropdown() {
|
||||
})}
|
||||
onClick={() => setIsOpen(true)}
|
||||
onMouseOver={() => setIsOpen(true)}
|
||||
aria-label="Open Navigation Dropdown"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute pointer-events-none invisible left-0 top-full z-[999] mt-2 w-48 min-w-[320px] -translate-y-1 rounded-lg bg-slate-800 py-2 opacity-0 shadow-xl transition-all duration-100',
|
||||
'pointer-events-none invisible absolute left-0 top-full z-[999] mt-2 w-48 min-w-[320px] -translate-y-1 rounded-lg bg-slate-800 py-2 opacity-0 shadow-xl transition-all duration-100',
|
||||
{
|
||||
'pointer-events-auto visible translate-y-2.5 opacity-100': isOpen,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet } from '../lib/http';
|
||||
import { httpGet, httpPatch, httpPost } 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;
|
||||
@@ -15,6 +16,7 @@ export type PageSponsorType = {
|
||||
};
|
||||
|
||||
type V1GetSponsorResponse = {
|
||||
id?: string;
|
||||
href?: string;
|
||||
sponsor?: PageSponsorType;
|
||||
};
|
||||
@@ -26,6 +28,8 @@ 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 () => {
|
||||
@@ -59,6 +63,7 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
}
|
||||
|
||||
setSponsor(response.sponsor);
|
||||
setSponsorId(response?.id || null);
|
||||
|
||||
window.fireEvent({
|
||||
category: 'SponsorImpression',
|
||||
@@ -69,6 +74,20 @@ 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);
|
||||
}, []);
|
||||
@@ -85,12 +104,13 @@ 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={() => {
|
||||
onClick={async () => {
|
||||
window.fireEvent({
|
||||
category: 'SponsorClick',
|
||||
action: `${company} Redirect`,
|
||||
label: gaLabel || `${gaPageIdentifier} / ${company} Link`,
|
||||
});
|
||||
await clickSponsor(sponsorId || '');
|
||||
}}
|
||||
>
|
||||
<span
|
||||
|
||||
19
src/components/ReactIcons/YouTubeIcon.tsx
Normal file
19
src/components/ReactIcons/YouTubeIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
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,22 +1,39 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
type SelectionButtonProps = {
|
||||
icon?: LucideIcon;
|
||||
text: string;
|
||||
isDisabled: boolean;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export function SelectionButton(props: SelectionButtonProps) {
|
||||
const { text, isDisabled, isSelected, onClick } = props;
|
||||
const {
|
||||
icon: Icon,
|
||||
text,
|
||||
isDisabled,
|
||||
isSelected,
|
||||
onClick,
|
||||
className,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`rounded-md border p-1 px-2 text-sm ${
|
||||
isSelected ? ' border-gray-500 bg-gray-300 ' : ''
|
||||
} ${
|
||||
!isDisabled ? ' cursor-pointer ' : ' cursor-not-allowed opacity-40 '
|
||||
}`}
|
||||
{...rest}
|
||||
className={cn(
|
||||
'rounded-md flex items-center border p-1 px-2 text-sm',
|
||||
isSelected ? 'border-gray-500 bg-gray-300' : '',
|
||||
!isDisabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-40',
|
||||
className,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{Icon && <Icon size={13} className="mr-1.5" />}
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { type ReactNode, useCallback, useState, useMemo } from 'react';
|
||||
import { Globe2, Loader2, Lock } from 'lucide-react';
|
||||
import { type ListFriendsResponse, ShareFriendList } from './ShareFriendList';
|
||||
import { TransferToTeamList } from './TransferToTeamList';
|
||||
@@ -37,6 +31,7 @@ type ShareOptionsModalProps = {
|
||||
teamId?: string;
|
||||
roadmapId?: string;
|
||||
description?: string;
|
||||
roadmapSlug?: string;
|
||||
|
||||
onShareSettingsUpdate: OnShareSettingsUpdate;
|
||||
};
|
||||
@@ -44,6 +39,7 @@ type ShareOptionsModalProps = {
|
||||
export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
const {
|
||||
roadmapId,
|
||||
roadmapSlug,
|
||||
onClose,
|
||||
isDiscoverable: defaultIsDiscoverable = false,
|
||||
visibility: defaultVisibility,
|
||||
@@ -68,10 +64,10 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
const [visibility, setVisibility] = useState(defaultVisibility);
|
||||
const [isDiscoverable, setIsDiscoverable] = useState(defaultIsDiscoverable);
|
||||
const [sharedTeamMemberIds, setSharedTeamMemberIds] = useState<string[]>(
|
||||
defaultSharedMemberIds
|
||||
defaultSharedMemberIds,
|
||||
);
|
||||
const [sharedFriendIds, setSharedFriendIds] = useState<string[]>(
|
||||
defaultSharedFriendIds
|
||||
defaultSharedFriendIds,
|
||||
);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
|
||||
@@ -120,7 +116,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
sharedFriendIds,
|
||||
sharedTeamMemberIds,
|
||||
isDiscoverable,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -151,7 +147,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
teamId,
|
||||
sharedTeamMemberIds,
|
||||
isDiscoverable,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -162,7 +158,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
|
||||
window.location.reload();
|
||||
},
|
||||
[roadmapId]
|
||||
[roadmapId],
|
||||
);
|
||||
|
||||
if (isSettingsUpdated) {
|
||||
@@ -173,6 +169,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
bodyClassName="p-4 flex flex-col"
|
||||
>
|
||||
<ShareSuccess
|
||||
roadmapSlug={roadmapSlug}
|
||||
visibility={visibility}
|
||||
roadmapId={roadmapId!}
|
||||
description={description}
|
||||
@@ -212,11 +209,11 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
setSharedFriendIds([]);
|
||||
} else if (visibility === 'friends') {
|
||||
setSharedFriendIds(
|
||||
defaultSharedFriendIds.length > 0 ? defaultSharedFriendIds : []
|
||||
defaultSharedFriendIds.length > 0 ? defaultSharedFriendIds : [],
|
||||
);
|
||||
} else if (visibility === 'team' && teamId) {
|
||||
setSharedTeamMemberIds(
|
||||
defaultSharedMemberIds?.length > 0 ? defaultSharedMemberIds : []
|
||||
defaultSharedMemberIds?.length > 0 ? defaultSharedMemberIds : [],
|
||||
);
|
||||
setSharedFriendIds([]);
|
||||
} else {
|
||||
@@ -329,7 +326,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
}
|
||||
onClick={() => {
|
||||
handleTransferToTeam(selectedTeamId!, sharedTeamMemberIds).then(
|
||||
() => null
|
||||
() => null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -374,7 +371,7 @@ function UpdateAction(props: {
|
||||
className={cn(
|
||||
'flex min-w-[120px] items-center justify-center gap-1.5 rounded-md border border-gray-900 bg-gray-900 px-4 py-2 text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-75',
|
||||
disabled && 'border-gray-700 bg-gray-700 text-white hover:bg-gray-700',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '../../lib/classname';
|
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
|
||||
type ShareSuccessProps = {
|
||||
roadmapSlug?: string;
|
||||
roadmapId: string;
|
||||
onClose: () => void;
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
@@ -13,6 +14,7 @@ type ShareSuccessProps = {
|
||||
|
||||
export function ShareSuccess(props: ShareSuccessProps) {
|
||||
const {
|
||||
roadmapSlug,
|
||||
roadmapId,
|
||||
onClose,
|
||||
description,
|
||||
@@ -23,7 +25,9 @@ export function ShareSuccess(props: ShareSuccessProps) {
|
||||
const baseUrl = import.meta.env.DEV
|
||||
? 'http://localhost:3000'
|
||||
: 'https://roadmap.sh';
|
||||
const shareLink = `${baseUrl}/r?id=${roadmapId}`;
|
||||
const shareLink = roadmapSlug
|
||||
? `${baseUrl}/r/${roadmapSlug}`
|
||||
: `${baseUrl}/r?id=${roadmapId}`;
|
||||
|
||||
const { copyText, isCopied } = useCopyText();
|
||||
|
||||
@@ -84,13 +88,13 @@ export function ShareSuccess(props: ShareSuccessProps) {
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(embedHtml);
|
||||
}}
|
||||
readOnly={true}
|
||||
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
|
||||
value={embedHtml}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(embedHtml);
|
||||
}}
|
||||
readOnly={true}
|
||||
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
|
||||
value={embedHtml}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,7 +131,7 @@ export function ShareSuccess(props: ShareSuccessProps) {
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-1.5 rounded bg-black px-4 py-2.5 text-sm font-medium text-white hover:opacity-80',
|
||||
isCopied && 'bg-green-300 text-green-800'
|
||||
isCopied && 'bg-green-300 text-green-800',
|
||||
)}
|
||||
disabled={isCopied}
|
||||
onClick={() => {
|
||||
@@ -139,7 +143,7 @@ export function ShareSuccess(props: ShareSuccessProps) {
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-1.5 rounded border border-black px-4 py-2 text-sm font-medium hover:bg-gray-100'
|
||||
'flex w-full items-center justify-center gap-1.5 rounded border border-black px-4 py-2 text-sm font-medium hover:bg-gray-100',
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
98
src/components/TableOfContent/TableOfContent.tsx
Normal file
98
src/components/TableOfContent/TableOfContent.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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 } =
|
||||
const { members, resourceTitle, resourceId, isCustomResource, roadmapSlug } =
|
||||
props.roadmap;
|
||||
|
||||
const { t: teamId } = getUrlParams();
|
||||
@@ -19,7 +19,7 @@ export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const roadmapLink = isCustomResource
|
||||
? `/r?id=${resourceId}`
|
||||
? `/r/${roadmapSlug}`
|
||||
: `/${resourceId}?t=${teamId}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,7 @@ export type UserProgress = {
|
||||
total: number;
|
||||
updatedAt: string;
|
||||
isCustomResource?: boolean;
|
||||
roadmapSlug?: string;
|
||||
};
|
||||
|
||||
export type TeamMember = {
|
||||
@@ -39,6 +40,7 @@ export type GroupByRoadmap = {
|
||||
resourceTitle: string;
|
||||
resourceType: string;
|
||||
isCustomResource?: boolean;
|
||||
roadmapSlug?: string;
|
||||
members: {
|
||||
member: TeamMember;
|
||||
progress: UserProgress | undefined;
|
||||
@@ -71,7 +73,7 @@ export function TeamProgressPage() {
|
||||
|
||||
async function getTeamProgress() {
|
||||
const { response, error } = await httpGet<TeamMember[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-progress/${teamId}`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-progress/${teamId}`,
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to get team progress');
|
||||
@@ -87,7 +89,7 @@ export function TeamProgressPage() {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,7 +118,7 @@ export function TeamProgressPage() {
|
||||
const members: GroupByRoadmap['members'] = [];
|
||||
for (const member of teamMembers) {
|
||||
const progress = member.progress.find(
|
||||
(progress) => progress.resourceId === roadmap
|
||||
(progress) => progress.resourceId === roadmap,
|
||||
);
|
||||
if (!progress) {
|
||||
continue;
|
||||
@@ -139,6 +141,7 @@ export function TeamProgressPage() {
|
||||
resourceId: roadmap,
|
||||
resourceTitle: members?.[0].progress?.resourceTitle || '',
|
||||
resourceType: 'roadmap',
|
||||
roadmapSlug: members?.[0].progress?.roadmapSlug,
|
||||
members,
|
||||
isCustomResource,
|
||||
});
|
||||
@@ -174,7 +177,7 @@ export function TeamProgressPage() {
|
||||
setShowMemberProgress({
|
||||
resourceId: showMemberProgress.resourceId,
|
||||
member: teamMembers.find(
|
||||
(member) => member.email === user?.email
|
||||
(member) => member.email === user?.email,
|
||||
)!,
|
||||
isCustomResource: showMemberProgress.isCustomResource,
|
||||
});
|
||||
|
||||
@@ -473,7 +473,7 @@ export function TeamRoadmaps() {
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`/r?id=${resourceConfig.resourceId}`}
|
||||
href={`/r/${resourceConfig.roadmapSlug}`}
|
||||
className={
|
||||
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
|
||||
}
|
||||
|
||||
@@ -27,8 +27,13 @@ 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;
|
||||
};
|
||||
@@ -43,7 +48,7 @@ const linkTypes: Record<AllowedLinkTypes, string> = {
|
||||
};
|
||||
|
||||
export function TopicDetail(props: TopicDetailProps) {
|
||||
const { canSubmitContribution, isEmbed = false } = props;
|
||||
const { canSubmitContribution, isEmbed = false, resourceTitle } = props;
|
||||
|
||||
const [hasEnoughLinks, setHasEnoughLinks] = useState(false);
|
||||
const [contributionUrl, setContributionUrl] = useState('');
|
||||
@@ -53,6 +58,7 @@ 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();
|
||||
|
||||
@@ -168,8 +174,11 @@ 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 || '');
|
||||
@@ -198,6 +207,8 @@ 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'}>
|
||||
@@ -288,6 +299,32 @@ 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">
|
||||
|
||||
151
src/components/UpdateProfile/ProfileUsername.tsx
Normal file
151
src/components/UpdateProfile/ProfileUsername.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
48
src/components/UpdateProfile/SkillProfileAlert.tsx
Normal file
48
src/components/UpdateProfile/SkillProfileAlert.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
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,10 +7,7 @@ export function UpdateProfileForm() {
|
||||
const [name, setName] = useState('');
|
||||
const [avatar, setAvatar] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [github, setGithub] = useState('');
|
||||
const [twitter, setTwitter] = useState('');
|
||||
const [linkedin, setLinkedin] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
@@ -26,10 +23,6 @@ export function UpdateProfileForm() {
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-profile`,
|
||||
{
|
||||
name,
|
||||
github: github || undefined,
|
||||
linkedin: linkedin || undefined,
|
||||
twitter: twitter || undefined,
|
||||
website: website || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -58,14 +51,11 @@ export function UpdateProfileForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, email, links, avatar } = response;
|
||||
const { name, email, avatar, username } = response;
|
||||
|
||||
setName(name);
|
||||
setEmail(email);
|
||||
setGithub(links?.github || '');
|
||||
setLinkedin(links?.linkedin || '');
|
||||
setTwitter(links?.twitter || '');
|
||||
setWebsite(links?.website || '');
|
||||
setUsername(username);
|
||||
setAvatar(avatar || '');
|
||||
|
||||
setIsLoading(false);
|
||||
@@ -81,8 +71,10 @@ export function UpdateProfileForm() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 hidden md:block">
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Profile</h2>
|
||||
<p className="mt-2 text-gray-400">Update your profile details below.</p>
|
||||
<h2 className="text-2xl font-bold sm:text-3xl">Basic Information</h2>
|
||||
<p className="mt-0.5 text-gray-400">
|
||||
Update and set up your public profile below.
|
||||
</p>
|
||||
</div>
|
||||
<UploadProfilePicture
|
||||
type="avatar"
|
||||
@@ -113,12 +105,20 @@ export function UpdateProfileForm() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<a
|
||||
href="/account/settings"
|
||||
className="text-xs text-purple-700 underline hover:text-purple-800"
|
||||
>
|
||||
Visit settings page to change email
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
@@ -131,77 +131,6 @@ export function UpdateProfileForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="github"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
Github
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="github"
|
||||
id="github"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://github.com/username"
|
||||
value={github}
|
||||
onInput={(e) => setGithub((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="twitter"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
Twitter
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="twitter"
|
||||
id="twitter"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://twitter.com/username"
|
||||
value={twitter}
|
||||
onInput={(e) => setTwitter((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="linkedin"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
LinkedIn
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="linkedin"
|
||||
id="linkedin"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://www.linkedin.com/in/username/"
|
||||
value={linkedin}
|
||||
onInput={(e) => setLinkedin((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://example.com"
|
||||
value={website}
|
||||
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
@@ -217,7 +146,7 @@ export function UpdateProfileForm() {
|
||||
disabled={isLoading}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue'}
|
||||
{isLoading ? 'Please wait...' : 'Update Information'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
578
src/components/UpdateProfile/UpdatePublicProfileForm.tsx
Normal file
578
src/components/UpdateProfile/UpdatePublicProfileForm.tsx
Normal file
@@ -0,0 +1,578 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
99
src/components/UpdateProfile/VisibilityDropdown.tsx
Normal file
99
src/components/UpdateProfile/VisibilityDropdown.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ChevronDown, Globe, LockIcon } from 'lucide-react';
|
||||
import { type AllowedProfileVisibility } from '../../api/user.ts';
|
||||
import { pageProgressMessage } from '../../stores/page.ts';
|
||||
import { httpPatch } from '../../lib/http.ts';
|
||||
import { useToast } from '../../hooks/use-toast.ts';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
type VisibilityDropdownProps = {
|
||||
visibility: AllowedProfileVisibility;
|
||||
setVisibility: (visibility: AllowedProfileVisibility) => void;
|
||||
};
|
||||
|
||||
export function VisibilityDropdown(props: VisibilityDropdownProps) {
|
||||
const { visibility, setVisibility } = props;
|
||||
const toast = useToast();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setIsVisibilityDropdownOpen(false);
|
||||
});
|
||||
|
||||
const [isVisibilityDropdownOpen, setIsVisibilityDropdownOpen] =
|
||||
useState(false);
|
||||
|
||||
async function updateProfileVisibility(visibility: AllowedProfileVisibility) {
|
||||
pageProgressMessage.set('Updating profile visibility');
|
||||
setIsVisibilityDropdownOpen(false);
|
||||
|
||||
const { error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-public-profile-visibility`,
|
||||
{
|
||||
profileVisibility: visibility,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Something went wrong');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('');
|
||||
setVisibility(visibility);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisibilityDropdownOpen(true);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-lg border border-black py-1 pl-1.5 pr-2 text-sm capitalize text-black',
|
||||
{
|
||||
invisible: isVisibilityDropdownOpen,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{visibility === 'public' && <Globe className='mr-1' size={13} />}
|
||||
{visibility === 'private' && <LockIcon className='mr-1' size={13} />}
|
||||
{visibility}
|
||||
<ChevronDown size={13} className="ml-1" />
|
||||
</button>
|
||||
{isVisibilityDropdownOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-0 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 py-2.5 pl-3 pr-3.5 text-left text-sm hover:bg-gray-100',
|
||||
{
|
||||
'bg-gray-200': visibility === 'public',
|
||||
},
|
||||
)}
|
||||
onClick={() => updateProfileVisibility('public')}
|
||||
>
|
||||
<Globe size={13} />
|
||||
Public
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 py-2.5 pl-3 pr-3.5 text-left text-sm hover:bg-gray-100',
|
||||
{
|
||||
'bg-gray-200': visibility === 'private',
|
||||
},
|
||||
)}
|
||||
onClick={() => updateProfileVisibility('private')}
|
||||
>
|
||||
<LockIcon size={13} />
|
||||
Private
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/UserProgress/ModalLoader.tsx
Normal file
38
src/components/UserProgress/ModalLoader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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 { ProgressLoadingError } from './ProgressLoadingError';
|
||||
import { ModalLoader } from './ModalLoader.tsx';
|
||||
import { UserProgressModalHeader } from './UserProgressModalHeader';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
@@ -144,7 +144,13 @@ export function UserCustomProgressModal(props: ProgressMapProps) {
|
||||
}
|
||||
|
||||
if (isLoading || error) {
|
||||
return <ProgressLoadingError isLoading={isLoading} error={error || ''} />;
|
||||
return (
|
||||
<ModalLoader
|
||||
text={'Loading user progress..'}
|
||||
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 { ProgressLoadingError } from './ProgressLoadingError';
|
||||
import { ModalLoader } from './ModalLoader.tsx';
|
||||
import { UserProgressModalHeader } from './UserProgressModalHeader';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
@@ -187,7 +187,13 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
}
|
||||
|
||||
if (isLoading || error) {
|
||||
return <ProgressLoadingError isLoading={isLoading} error={error} />;
|
||||
return (
|
||||
<ModalLoader
|
||||
text={'Loading user progress..'}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
22
src/components/UserPublicProfile/PrivateProfileBanner.tsx
Normal file
22
src/components/UserPublicProfile/PrivateProfileBanner.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { GetPublicProfileResponse } from '../../api/user';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
type PrivateProfileBannerProps = Pick<
|
||||
GetPublicProfileResponse,
|
||||
'isOwnProfile' | 'profileVisibility'
|
||||
>;
|
||||
|
||||
export function PrivateProfileBanner(props: PrivateProfileBannerProps) {
|
||||
const { isOwnProfile, profileVisibility } = props;
|
||||
|
||||
if (isOwnProfile && profileVisibility === 'private') {
|
||||
return (
|
||||
<div className="-mb-4 -mt-5 rounded-lg border border-yellow-400 bg-yellow-100 p-2 text-center text-sm font-medium">
|
||||
<Lock className="-mt-1 mr-1.5 inline-block h-4 w-4" />
|
||||
Your profile is private. Only you can see this page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
109
src/components/UserPublicProfile/UserProfileRoadmap.tsx
Normal file
109
src/components/UserPublicProfile/UserProfileRoadmap.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import type {
|
||||
GetUserProfileRoadmapResponse,
|
||||
GetPublicProfileResponse,
|
||||
} from '../../api/user';
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { PrivateProfileBanner } from './PrivateProfileBanner';
|
||||
import { UserProfileRoadmapRenderer } from './UserProfileRoadmapRenderer';
|
||||
|
||||
type UserProfileRoadmapProps = GetUserProfileRoadmapResponse &
|
||||
Pick<
|
||||
GetPublicProfileResponse,
|
||||
'username' | 'name' | 'isOwnProfile' | 'profileVisibility'
|
||||
> & {
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
export function UserProfileRoadmap(props: UserProfileRoadmapProps) {
|
||||
const {
|
||||
username,
|
||||
name,
|
||||
title,
|
||||
resourceId,
|
||||
isCustomResource,
|
||||
done = [],
|
||||
skipped = [],
|
||||
learning = [],
|
||||
topicCount,
|
||||
isOwnProfile,
|
||||
profileVisibility,
|
||||
} = props;
|
||||
|
||||
const trackProgressRoadmapUrl = isCustomResource
|
||||
? `/r/${resourceId}`
|
||||
: `/${resourceId}`;
|
||||
|
||||
const totalMarked = done.length + skipped.length;
|
||||
const progressPercentage = getPercentage(totalMarked, topicCount);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrivateProfileBanner
|
||||
isOwnProfile={isOwnProfile}
|
||||
profileVisibility={profileVisibility}
|
||||
/>
|
||||
<div className="container mt-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex items-center gap-1 text-sm">
|
||||
<a
|
||||
href={`/u/${username}`}
|
||||
className="text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
{username}
|
||||
</a>
|
||||
<span>/</span>
|
||||
<a
|
||||
href={`/u/${username}/${resourceId}`}
|
||||
className="text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
{resourceId}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={trackProgressRoadmapUrl}
|
||||
className="rounded-md border px-2.5 py-1 text-sm font-medium"
|
||||
>
|
||||
Track your Progress
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-10 text-2xl font-bold sm:text-4xl">{title}</h2>
|
||||
<p className="mt-2 text-sm text-gray-500 sm:text-lg">
|
||||
Skills {name} has mastered on the {title?.toLowerCase()}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-50 mt-10 hidden items-center justify-between border-y bg-white px-2 py-1.5 sm:flex">
|
||||
<p className="container flex text-sm">
|
||||
<span className="mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
|
||||
<span data-progress-percentage="">{progressPercentage}</span>% Done
|
||||
</span>
|
||||
|
||||
<span className="itesm-center hidden md:flex">
|
||||
<span>
|
||||
<span>{done.length}</span> completed
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
src/components/UserPublicProfile/UserProfileRoadmapRenderer.tsx
Normal file
146
src/components/UserPublicProfile/UserProfileRoadmapRenderer.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import '../FrameRenderer/FrameRenderer.css';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import {
|
||||
renderTopicProgress,
|
||||
topicSelectorAll,
|
||||
} from '../../lib/resource-progress';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { replaceChildren } from '../../lib/dom.ts';
|
||||
import type { GetUserProfileRoadmapResponse } from '../../api/user.ts';
|
||||
import { ReadonlyEditor } from '../../../editor/readonly-editor.tsx';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
export type UserProfileRoadmapRendererProps = GetUserProfileRoadmapResponse & {
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
};
|
||||
|
||||
export function UserProfileRoadmapRenderer(
|
||||
props: UserProfileRoadmapRendererProps,
|
||||
) {
|
||||
const {
|
||||
resourceId,
|
||||
resourceType,
|
||||
done,
|
||||
skipped,
|
||||
learning,
|
||||
edges,
|
||||
nodes,
|
||||
isCustomResource,
|
||||
} = props;
|
||||
|
||||
const containerEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(!isCustomResource);
|
||||
const toast = useToast();
|
||||
|
||||
let resourceJsonUrl = 'https://roadmap.sh';
|
||||
if (resourceType === 'roadmap') {
|
||||
resourceJsonUrl += `/${resourceId}.json`;
|
||||
} else {
|
||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||
}
|
||||
|
||||
async function renderResource(jsonUrl: string) {
|
||||
const res = await fetch(jsonUrl, {});
|
||||
const json = await res.json();
|
||||
const { wireframeJSONToSVG } = await import('roadmap-renderer');
|
||||
const svg: SVGElement | null = await wireframeJSONToSVG(json, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
|
||||
replaceChildren(containerEl.current!, svg);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!containerEl.current ||
|
||||
!resourceJsonUrl ||
|
||||
!resourceId ||
|
||||
!resourceType ||
|
||||
isCustomResource
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
renderResource(resourceJsonUrl)
|
||||
.then(() => {
|
||||
done.forEach((id: string) => renderTopicProgress(id, 'done'));
|
||||
learning.forEach((id: string) => renderTopicProgress(id, 'learning'));
|
||||
skipped.forEach((id: string) => renderTopicProgress(id, 'skipped'));
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error(err?.message || 'Something went wrong. Please try again!');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div id="customized-roadmap">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white',
|
||||
isCustomResource ? 'w-full' : 'container relative !max-w-[1000px]',
|
||||
)}
|
||||
>
|
||||
{isCustomResource ? (
|
||||
<ReadonlyEditor
|
||||
roadmap={{
|
||||
nodes,
|
||||
edges,
|
||||
}}
|
||||
className="min-h-[1000px]"
|
||||
onRendered={(wrapperRef: RefObject<HTMLDivElement>) => {
|
||||
done?.forEach((topicId: string) => {
|
||||
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
|
||||
(el) => {
|
||||
el.classList.add('done');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
learning?.forEach((topicId: string) => {
|
||||
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
|
||||
(el) => {
|
||||
el.classList.add('learning');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
skipped?.forEach((topicId: string) => {
|
||||
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
|
||||
(el) => {
|
||||
el.classList.add('skipped');
|
||||
},
|
||||
);
|
||||
});
|
||||
}}
|
||||
fontFamily="Balsamiq Sans"
|
||||
fontURL="/fonts/balsamiq.woff2"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
id={'resource-svg-wrap'}
|
||||
ref={containerEl}
|
||||
className="pointer-events-none px-4 pb-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex w-full justify-center">
|
||||
<Spinner
|
||||
isDualRing={false}
|
||||
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/components/UserPublicProfile/UserPublicActivityHeatmap.tsx
Normal file
110
src/components/UserPublicProfile/UserPublicActivityHeatmap.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import CalendarHeatmap from 'react-calendar-heatmap';
|
||||
import { Tooltip as ReactTooltip } from 'react-tooltip';
|
||||
import 'react-calendar-heatmap/dist/styles.css';
|
||||
import 'react-tooltip/dist/react-tooltip.css';
|
||||
import { formatActivityDate, formatMonthDate } from '../../lib/date';
|
||||
import type { UserActivityCount } from '../../api/user';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
type UserActivityHeatmapProps = {
|
||||
activity: UserActivityCount;
|
||||
joinedAt: string;
|
||||
};
|
||||
|
||||
const legends = [
|
||||
{ count: '1-2', color: 'bg-gray-200' },
|
||||
{ count: '3-4', color: 'bg-gray-300' },
|
||||
{ count: '5-9', color: 'bg-gray-500' },
|
||||
{ count: '10-19', color: 'bg-gray-600' },
|
||||
{ count: '20+', color: 'bg-gray-800' },
|
||||
];
|
||||
|
||||
export function UserActivityHeatmap(props: UserActivityHeatmapProps) {
|
||||
const { activity } = props;
|
||||
const data = Object.entries(activity.activityCount).map(([date, count]) => ({
|
||||
date,
|
||||
count,
|
||||
}));
|
||||
|
||||
const startDate = dayjs().subtract(1, 'year').toDate();
|
||||
const endDate = dayjs().toDate();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4">
|
||||
<div className="-mx-4 mb-8 flex justify-between border-b px-4 pb-3">
|
||||
<div className="">
|
||||
<h2 className="mb-0.5 font-semibold">Activity</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Progress updates over the past year
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">
|
||||
Member since: {formatMonthDate(props.joinedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<CalendarHeatmap
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
values={data}
|
||||
classForValue={(value) => {
|
||||
if (!value) {
|
||||
return 'fill-gray-100 rounded-md [rx:2px] focus:outline-none';
|
||||
}
|
||||
|
||||
const { count } = value;
|
||||
if (count >= 20) {
|
||||
return 'fill-gray-800 rounded-md [rx:2px] focus:outline-none';
|
||||
} else if (count >= 10) {
|
||||
return 'fill-gray-600 rounded-md [rx:2px] focus:outline-none';
|
||||
} else if (count >= 5) {
|
||||
return 'fill-gray-500 rounded-md [rx:2px] focus:outline-none';
|
||||
} else if (count >= 3) {
|
||||
return 'fill-gray-300 rounded-md [rx:2px] focus:outline-none';
|
||||
} else {
|
||||
return 'fill-gray-200 rounded-md [rx:2px] focus:outline-none';
|
||||
}
|
||||
}}
|
||||
tooltipDataAttrs={(value: any) => {
|
||||
if (!value || !value.date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedDate = formatActivityDate(value.date);
|
||||
return {
|
||||
'data-tooltip-id': 'user-activity-tip',
|
||||
'data-tooltip-content': `${value.count} Updates - ${formattedDate}`,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
<ReactTooltip
|
||||
id="user-activity-tip"
|
||||
className="!rounded-lg !bg-gray-900 !p-1 !px-2 !text-sm"
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">
|
||||
Number of topics marked as learning, or completed by day
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2 text-xs text-gray-500">Less</span>
|
||||
{legends.map((legend) => (
|
||||
<div
|
||||
key={legend.count}
|
||||
className="flex items-center"
|
||||
data-tooltip-id="user-activity-tip"
|
||||
data-tooltip-content={`${legend.count} Updates`}
|
||||
>
|
||||
<div className={`h-3 w-3 ${legend.color} mr-1 rounded-sm`}></div>
|
||||
</div>
|
||||
))}
|
||||
<span className="ml-2 text-xs text-gray-500">More</span>
|
||||
<ReactTooltip
|
||||
id="user-activity-tip"
|
||||
className="!rounded-lg !bg-gray-900 !p-1 !px-2 !text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/components/UserPublicProfile/UserPublicProfileHeader.tsx
Normal file
65
src/components/UserPublicProfile/UserPublicProfileHeader.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Github, Globe, LinkedinIcon, Mail, Twitter } from 'lucide-react';
|
||||
import type { GetPublicProfileResponse } from '../../api/user';
|
||||
|
||||
type UserPublicProfileHeaderProps = {
|
||||
userDetails: GetPublicProfileResponse;
|
||||
};
|
||||
|
||||
export function UserPublicProfileHeader(props: UserPublicProfileHeaderProps) {
|
||||
const { userDetails } = props;
|
||||
|
||||
const { name, links, publicConfig, avatar, email } = userDetails;
|
||||
const { headline, isAvailableForHire, isEmailVisible } = publicConfig!;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-6 container bg-white border p-8 rounded-xl">
|
||||
<img
|
||||
src={
|
||||
avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={name}
|
||||
className="h-32 w-32 rounded-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
{isAvailableForHire && (
|
||||
<span className="mb-1 inline-block rounded-md bg-green-100 px-2 py-1 text-sm text-green-700">
|
||||
Available for hire
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold">{name}</h1>
|
||||
<p className="mt-1 text-base text-gray-500">{headline}</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{links?.github && <UserLink href={links?.github} icon={Github} />}
|
||||
{links?.linkedin && (
|
||||
<UserLink href={links?.linkedin} icon={LinkedinIcon} />
|
||||
)}
|
||||
{links?.twitter && <UserLink href={links?.twitter} icon={Twitter} />}
|
||||
{links?.website && <UserLink href={links?.website} icon={Globe} />}
|
||||
{isEmailVisible && <UserLink href={`mailto:${email}`} icon={Mail} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type UserLinkProps = {
|
||||
href: string;
|
||||
icon: typeof Github;
|
||||
};
|
||||
|
||||
export function UserLink(props: UserLinkProps) {
|
||||
const { href, icon: Icon } = props;
|
||||
|
||||
return (
|
||||
<a
|
||||
target="_blank"
|
||||
href={href}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md border"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 stroke-2" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
39
src/components/UserPublicProfile/UserPublicProfilePage.tsx
Normal file
39
src/components/UserPublicProfile/UserPublicProfilePage.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { GetPublicProfileResponse } from '../../api/user';
|
||||
import { PrivateProfileBanner } from './PrivateProfileBanner';
|
||||
import { UserActivityHeatmap } from './UserPublicActivityHeatmap';
|
||||
import { UserPublicProfileHeader } from './UserPublicProfileHeader';
|
||||
import { UserPublicProgresses } from './UserPublicProgresses';
|
||||
|
||||
type UserPublicProfilePageProps = GetPublicProfileResponse;
|
||||
|
||||
export function UserPublicProfilePage(props: UserPublicProfilePageProps) {
|
||||
const {
|
||||
activity,
|
||||
username,
|
||||
isOwnProfile,
|
||||
profileVisibility,
|
||||
_id: userId,
|
||||
createdAt,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-200/40 min-h-full flex-grow pt-10 pb-36">
|
||||
<div className="container flex flex-col gap-8">
|
||||
<PrivateProfileBanner
|
||||
isOwnProfile={isOwnProfile}
|
||||
profileVisibility={profileVisibility}
|
||||
/>
|
||||
|
||||
<UserPublicProfileHeader userDetails={props!} />
|
||||
|
||||
<UserActivityHeatmap joinedAt={createdAt} activity={activity!} />
|
||||
<UserPublicProgresses
|
||||
username={username!}
|
||||
userId={userId!}
|
||||
roadmaps={props.roadmaps}
|
||||
publicConfig={props.publicConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/UserPublicProfile/UserPublicProgressStats.tsx
Normal file
70
src/components/UserPublicProfile/UserPublicProgressStats.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
|
||||
type UserPublicProgressStats = {
|
||||
resourceType: 'roadmap';
|
||||
resourceId: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
totalCount: number;
|
||||
doneCount: number;
|
||||
learningCount: number;
|
||||
skippedCount: number;
|
||||
showClearButton?: boolean;
|
||||
isCustomResource?: boolean;
|
||||
roadmapSlug?: string;
|
||||
username: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export function UserPublicProgressStats(props: UserPublicProgressStats) {
|
||||
const {
|
||||
updatedAt,
|
||||
resourceId,
|
||||
title,
|
||||
totalCount,
|
||||
learningCount,
|
||||
doneCount,
|
||||
skippedCount,
|
||||
roadmapSlug,
|
||||
isCustomResource = false,
|
||||
username,
|
||||
userId,
|
||||
} = props;
|
||||
|
||||
// Currently we only support roadmap not (best-practices)
|
||||
const url = isCustomResource
|
||||
? `/r/${roadmapSlug}`
|
||||
: `/${resourceId}?s=${userId}`;
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = getPercentage(totalMarked, totalCount);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="group block rounded-md border p-2.5"
|
||||
>
|
||||
<h3 className="flex-1 cursor-pointer truncate text-lg font-medium">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="relative mt-5 h-1 w-full overflow-hidden rounded-full bg-black/5">
|
||||
<div
|
||||
className={`absolute left-0 top-0 h-full bg-black/40`}
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
{progressPercentage}% completed
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">
|
||||
Last updated {getRelativeTimeString(updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
97
src/components/UserPublicProfile/UserPublicProgresses.tsx
Normal file
97
src/components/UserPublicProfile/UserPublicProgresses.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
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 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.
|
||||
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.
|
||||
topics:
|
||||
- 'SSR'
|
||||
- 'Intermediate'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Basic Functions of a Data Analyst
|
||||
# If
|
||||
|
||||
A Data Analyst serves a pivotal role in the decision-making processes within an organization. The basic function of a data analyst involves collecting, processing, and performing statistical analyses of data. Their work encompasses understanding the nature of data, finding out the patterns and insights hidden within them, and communicating these findings in a manner that can facilitate the decision-making of the company. They are often tasked to transform complex data into a format that is easily understandable, which enables the company to make informed decisions. This may involve designing and maintaining databases and data systems, conducting analysis to identify trends, and creating visualizations of their findings. These basic functions are the cornerstones upon which a data analyst builds more complex and organization-specific responsibilities from.
|
||||
The IF function in Excel is a crucial tool for data analysts, enabling them to create conditional statements, clean and validate data, perform calculations based on specific conditions, create custom metrics, apply conditional formatting, automate tasks, and generate dynamic reports. Data analysts use IF to categorize data, handle missing values, calculate bonuses or custom metrics, highlight trends, and enhance visualizations, ultimately facilitating informed decision-making through data analysis.
|
||||
|
||||
@@ -11,6 +11,6 @@ Visit the following resources to learn more:
|
||||
- [Learn the ways of Linux-fu, for free](https://linuxjourney.com/)
|
||||
- [Linux Operating System - Crash Course for Beginners](https://www.youtube.com/watch?v=ROjZy1WbCIA)
|
||||
- [The Linux Command Line by William Shotts](https://linuxcommand.org/tlcl.php)
|
||||
- [r/linuxupskillchallenge](https://www.reddit.com/r/linuxupskillchallenge/)
|
||||
- [Linux Upskill Challenge](https://linuxupskillchallenge.org/)
|
||||
- [Introduction to Linux - Full Course for Beginners](https://www.youtube.com/watch?v=sWbUDq4S6Y8&pp=ygUTVWJ1bnR1IGNyYXNoIGNvdXJzZQ%3D%3D)
|
||||
- [Linux Fundamentals](https://academy.hackthebox.com/course/preview/linux-fundamentals)
|
||||
- [Linux Fundamentals](https://academy.hackthebox.com/course/preview/linux-fundamentals)
|
||||
|
||||
@@ -6,7 +6,7 @@ This temporary or short-lived storage is called the "ephemeral container file sy
|
||||
|
||||
### Ephemeral FS and Data Persistence
|
||||
|
||||
As any data stored within the container's ephemeral FS is lost when the container is stopped or removed, it poses a challenge to data persistence in applications. This is especially problematic for applications like databases, which require data to be persisted across multiple container life cycles.
|
||||
As any data stored within the container's ephemeral FS is lost when the container is stopped and removed, it poses a challenge to data persistence in applications. This is especially problematic for applications like databases, which require data to be persisted across multiple container life cycles.
|
||||
|
||||
To overcome these challenges, Docker provides several methods for data persistence, such as:
|
||||
|
||||
@@ -14,4 +14,4 @@ To overcome these challenges, Docker provides several methods for data persisten
|
||||
- **Bind mounts**: Mapping a host machine's directory or file into a container, effectively sharing host's storage with the container.
|
||||
- **tmpfs mounts**: In-memory storage, useful for cases where just the persistence of data within the life-cycle of the container is required.
|
||||
|
||||
By implementing these strategies, Docker ensures that application data can be preserved beyond the life-cycle of a single container, making it possible to work with stateful applications.
|
||||
By implementing these strategies, Docker ensures that application data can be preserved beyond the life-cycle of a single container, making it possible to work with stateful applications.
|
||||
|
||||
@@ -9,4 +9,4 @@ Lighthouse provides a comprehensive and easy-to-use tool for identifying and fix
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [Lighthouse - Google Developers](https://developers.google.com/web/tools/lighthouse)
|
||||
- [Improving Load Performance - Chrome DevTools 101](https://www.youtube.com/watch?v=5flw5q5odie)
|
||||
- [Improving Load Performance - Chrome DevTools 101](https://www.youtube.com/watch?v=5fLW5Q5ODiE)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import type { FAQType } from '../../components/FAQs/FAQs.astro';
|
||||
import type { FAQType } from '../../../components/FAQs/FAQs.astro';
|
||||
|
||||
export const faqs: FAQType[] = [
|
||||
{
|
||||
|
||||
@@ -5,4 +5,4 @@ JavaScript was initially created by Brendan Eich of NetScape and was first annou
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [Brief History of JavaScript](https://roadmap.sh/guides/history-of-javascript)
|
||||
- [The History of JavaScript](https://dev.to/iarchitsharma/the-history-of-javascript-5e98)
|
||||
- [The Weird History of JavaScript](https://dev.to/codediodeio/the-weird-history-of-javascript-2bnb)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Chat I/O
|
||||
# Chatty I/O
|
||||
|
||||
The cumulative effect of a large number of I/O requests can have a significant impact on performance and responsiveness.
|
||||
|
||||
|
||||
9
src/helper/number.ts
Normal file
9
src/helper/number.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function getPercentage(portion: number, total: number): string {
|
||||
if (total <= 0 || portion <= 0) {
|
||||
return '0';
|
||||
} else if (portion > total) {
|
||||
return '100';
|
||||
}
|
||||
|
||||
return ((portion / total) * 100).toFixed(2);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MarkdownFileType } from './file';
|
||||
import type { BestPracticeFrontmatter } from './best-pratice';
|
||||
import type { BestPracticeFrontmatter } from './best-practice';
|
||||
|
||||
// Generates URL from the topic file path e.g.
|
||||
// -> /src/data/best-practices/frontend-performance/content/100-use-https-everywhere
|
||||
@@ -34,7 +34,7 @@ export async function getAllBestPracticeTopicFiles(): Promise<
|
||||
'/src/data/best-practices/*/content/**/*.md',
|
||||
{
|
||||
eager: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const mapping: Record<string, BestPracticeTopicFileType> = {};
|
||||
@@ -63,4 +63,4 @@ export async function getAllBestPracticeTopicFiles(): Promise<
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +32,17 @@ export function getRelativeTimeString(date: string): string {
|
||||
|
||||
return relativeTime;
|
||||
}
|
||||
|
||||
export function formatMonthDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatActivityDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,3 +87,33 @@ export async function getGuideById(
|
||||
|
||||
return allGuides.find((guide) => guide.id === id);
|
||||
}
|
||||
|
||||
type HeadingType = ReturnType<MarkdownFileType['getHeadings']>[number];
|
||||
export type HeadingGroupType = HeadingType & { children: HeadingType[] };
|
||||
|
||||
const NUMBERED_LIST_REGEX = /^\d+\.\s+?/;
|
||||
|
||||
export function getGuideTableOfContent(headings: HeadingType[]) {
|
||||
const tableOfContents: HeadingGroupType[] = [];
|
||||
let currentGroup: HeadingGroupType | null = null;
|
||||
|
||||
headings
|
||||
.filter((heading) => heading.depth !== 1)
|
||||
.forEach((heading) => {
|
||||
if (heading.depth === 2) {
|
||||
currentGroup = {
|
||||
...heading,
|
||||
text: heading.text.replace(NUMBERED_LIST_REGEX, ''),
|
||||
children: [],
|
||||
};
|
||||
tableOfContents.push(currentGroup);
|
||||
} else if (currentGroup && heading.depth === 3) {
|
||||
currentGroup.children.push({
|
||||
...heading,
|
||||
text: heading.text.replace(NUMBERED_LIST_REGEX, ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tableOfContents;
|
||||
}
|
||||
|
||||
@@ -109,3 +109,19 @@ export function removeAIReferralCode() {
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
}
|
||||
|
||||
export function setViewSponsorCookie(sponsorId: string) {
|
||||
const key = `vsc-${sponsorId}`;
|
||||
const alreadyExist = Cookies.get(key);
|
||||
if (alreadyExist) {
|
||||
return;
|
||||
}
|
||||
|
||||
Cookies.set(key, '1', {
|
||||
path: '/',
|
||||
expires: 1,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function getAllLinkGroups(): Promise<LinkGroupFileType[]> {
|
||||
'/src/data/link-groups/*.md',
|
||||
{
|
||||
eager: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return Object.values(linkGroups).map((linkGroupFile) => ({
|
||||
@@ -37,3 +37,14 @@ export async function getAllLinkGroups(): Promise<LinkGroupFileType[]> {
|
||||
id: linkGroupPathToId(linkGroupFile.file),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getLinkGroupById(
|
||||
groupId: string,
|
||||
): Promise<LinkGroupFileType> {
|
||||
const linkGroup = await import(`../data/link-groups/${groupId}.md`);
|
||||
|
||||
return {
|
||||
...linkGroup,
|
||||
id: linkGroupPathToId(linkGroup.file),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,3 +6,8 @@ type RoadmapOpenGraphQuery = {
|
||||
export function getOpenGraphImageUrl(params: RoadmapOpenGraphQuery) {
|
||||
return `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/og-images/${params.group}/${params.resourceId}.png`;
|
||||
}
|
||||
|
||||
export async function getDefaultOpenGraphImageBuffer() {
|
||||
const defaultImageUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/images/og-img.png`;
|
||||
return fetch(defaultImageUrl).then((response) => response.arrayBuffer());
|
||||
}
|
||||
|
||||
@@ -118,6 +118,12 @@ export async function getAllQuestionGroups(): Promise<QuestionGroupType[]> {
|
||||
.sort((a, b) => a.frontmatter.order - b.frontmatter.order);
|
||||
}
|
||||
|
||||
export async function getQuestionGroupById(id: string) {
|
||||
const questionGroups = await getAllQuestionGroups();
|
||||
|
||||
return questionGroups.find((group) => group.id === id);
|
||||
}
|
||||
|
||||
export async function getQuestionGroupsByIds(
|
||||
ids: string[],
|
||||
): Promise<{ id: string; title: string; description: string }[]> {
|
||||
|
||||
@@ -128,3 +128,11 @@ export async function getRoadmapsByIds(
|
||||
|
||||
return Promise.all(ids.map((id) => getRoadmapById(id)));
|
||||
}
|
||||
|
||||
export async function getRoadmapFaqsById(roadmapId: string): Promise<string[]> {
|
||||
const { faqs } = await import(
|
||||
`../data/roadmaps/${roadmapId}/faqs.astro`
|
||||
).catch(() => ({}));
|
||||
|
||||
return faqs || [];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { MarkdownFileType } from './file';
|
||||
import type { AuthorFileType } from './author.ts';
|
||||
import { getAllAuthors } from './author.ts';
|
||||
import type {GuideFileType} from "./guide.ts";
|
||||
import {getAllGuides} from "./guide.ts";
|
||||
import type { GuideFileType } from './guide.ts';
|
||||
import { getAllGuides } from './guide.ts';
|
||||
|
||||
export interface VideoFrontmatter {
|
||||
title: string;
|
||||
@@ -40,7 +40,7 @@ function videoPathToId(filePath: string): string {
|
||||
}
|
||||
|
||||
export async function getVideosByAuthor(
|
||||
authorId: string,
|
||||
authorId: string,
|
||||
): Promise<VideoFileType[]> {
|
||||
const allVideos = await getAllVideos();
|
||||
|
||||
@@ -73,3 +73,22 @@ export async function getAllVideos(): Promise<VideoFileType[]> {
|
||||
new Date(a.frontmatter.date).valueOf(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getVideoById(id: string): Promise<VideoFileType> {
|
||||
const videoFilesMap: Record<string, VideoFileType> =
|
||||
import.meta.glob<VideoFileType>('../data/videos/*.md', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
const videoFile = Object.values(videoFilesMap).find((videoFile) => {
|
||||
return videoPathToId(videoFile.file) === id;
|
||||
});
|
||||
if (!videoFile) {
|
||||
throw new Error(`Video with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
...videoFile,
|
||||
id: videoPathToId(videoFile.file),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
import RoadmapBanner from '../../components/RoadmapBanner.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import {
|
||||
getRoadmapTopicFiles,
|
||||
type RoadmapTopicFileType,
|
||||
@@ -36,4 +34,4 @@ const gitHubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste
|
||||
|
||||
<div data-github-url={gitHubUrl}></div>
|
||||
|
||||
<file.Content />
|
||||
<file.Content />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import FAQs from '../../components/FAQs/FAQs.astro';
|
||||
import FAQs, { type FAQType } from '../../components/FAQs/FAQs.astro';
|
||||
import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro';
|
||||
import MarkdownFile from '../../components/MarkdownFile.astro';
|
||||
import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro';
|
||||
import RoadmapHeader from '../../components/RoadmapHeader.astro';
|
||||
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
|
||||
@@ -54,7 +53,7 @@ if (roadmapData.schema) {
|
||||
}
|
||||
|
||||
if (roadmapFAQs.length) {
|
||||
jsonLdSchema.push(generateFAQSchema(roadmapFAQs));
|
||||
jsonLdSchema.push(generateFAQSchema(roadmapFAQs as unknown as FAQType[]));
|
||||
}
|
||||
|
||||
const ogImageUrl =
|
||||
@@ -107,7 +106,12 @@ const ogImageUrl =
|
||||
description={roadmapData.briefDescription}
|
||||
pageUrl={`https://roadmap.sh/${roadmapId}`}
|
||||
/>
|
||||
<TopicDetail client:idle canSubmitContribution={true} />
|
||||
<TopicDetail
|
||||
resourceTitle={roadmapData.title}
|
||||
resourceType='roadmap'
|
||||
client:idle
|
||||
canSubmitContribution={true}
|
||||
/>
|
||||
|
||||
<FrameRenderer
|
||||
resourceType={'roadmap'}
|
||||
@@ -125,7 +129,7 @@ const ogImageUrl =
|
||||
client:only='react'
|
||||
/>
|
||||
|
||||
<FAQs faqs={roadmapFAQs} />
|
||||
<FAQs faqs={roadmapFAQs as unknown as FAQType[]} />
|
||||
|
||||
<RelatedRoadmaps roadmap={roadmapData} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const roadmapJsons = await import.meta.glob('/src/data/roadmaps/**/*.json', {
|
||||
const roadmapJsons = import.meta.glob('/src/data/roadmaps/**/*.json', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ import { ActivityPage } from '../../components/Activity/ActivityPage';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
<AccountLayout title='Update Profile' noIndex={true} initialLoadingMessage={'Loading activity'}>
|
||||
<AccountLayout
|
||||
title='Activity'
|
||||
noIndex={true}
|
||||
initialLoadingMessage={'Loading activity'}
|
||||
>
|
||||
<AccountSidebar activePageId='activity' activePageTitle='Activity'>
|
||||
<ActivityPage client:only="react" />
|
||||
<ActivityPage client:only='react' />
|
||||
</AccountSidebar>
|
||||
</AccountLayout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import AccountSidebar from '../../components/AccountSidebar.astro';
|
||||
import { UpdateProfileForm } from '../../components/UpdateProfile/UpdateProfileForm';
|
||||
import { UpdatePublicProfileForm } from '../../components/UpdateProfile/UpdatePublicProfileForm';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
@@ -10,6 +10,6 @@ import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
initialLoadingMessage={'Loading profile'}
|
||||
>
|
||||
<AccountSidebar activePageId='profile' activePageTitle='Profile'>
|
||||
<UpdateProfileForm client:load />
|
||||
<UpdatePublicProfileForm client:load />
|
||||
</AccountSidebar>
|
||||
</AccountLayout>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
---
|
||||
import LoginPopup from '../../components/AuthenticationFlow/LoginPopup.astro';
|
||||
import { ExploreAIRoadmap } from '../../components/ExploreAIRoadmap/ExploreAIRoadmap';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<AccountLayout title='Explore AI Generated Roadmaps'>
|
||||
<BaseLayout title='Explore AI Generated Roadmaps'>
|
||||
<ExploreAIRoadmap client:load />
|
||||
</AccountLayout>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
---
|
||||
import LoginPopup from '../../components/AuthenticationFlow/LoginPopup.astro';
|
||||
import { GenerateRoadmap } from '../../components/GenerateRoadmap/GenerateRoadmap';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<AccountLayout title='Roadmap AI'>
|
||||
<BaseLayout title='Roadmap AI'>
|
||||
<GenerateRoadmap client:load />
|
||||
<LoginPopup />
|
||||
</AccountLayout>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import AstroIcon from '../../components/AstroIcon.astro';
|
||||
import { getGuidesByAuthor } from '../../lib/guide';
|
||||
import { getAllVideos, getVideosByAuthor } from '../../lib/video';
|
||||
import { getVideosByAuthor } from '../../lib/video';
|
||||
import GuideListItem from '../../components/GuideListItem.astro';
|
||||
import { getAuthorById, getAuthorIds } from '../../lib/author';
|
||||
import VideoListItem from '../../components/VideoListItem.astro';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import GuideContent from '../../components/Guide/GuideContent.astro';
|
||||
import GuideHeader from '../../components/GuideHeader.astro';
|
||||
import MarkdownFile from '../../components/MarkdownFile.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getGuideById } from '../../lib/guide';
|
||||
import { getOpenGraphImageUrl } from '../../lib/open-graph';
|
||||
@@ -23,11 +23,6 @@ const ogImageUrl = getOpenGraphImageUrl({
|
||||
canonicalUrl={guideData.canonicalUrl}
|
||||
ogImageUrl={ogImageUrl}
|
||||
>
|
||||
<GuideHeader guide={guide} />
|
||||
|
||||
<div class='mx-auto max-w-[700px] py-5 sm:py-10'>
|
||||
<MarkdownFile>
|
||||
<guide.Content />
|
||||
</MarkdownFile>
|
||||
</div>
|
||||
<GuideHeader guide={guide!} />
|
||||
<GuideContent guide={guide!} />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import GuideContent from '../../components/Guide/GuideContent.astro';
|
||||
import GuideHeader from '../../components/GuideHeader.astro';
|
||||
import MarkdownFile from '../../components/MarkdownFile.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getGuideById } from '../../lib/guide';
|
||||
import { getOpenGraphImageUrl } from '../../lib/open-graph';
|
||||
@@ -23,11 +23,6 @@ const ogImageUrl = getOpenGraphImageUrl({
|
||||
canonicalUrl={guideData.canonicalUrl}
|
||||
ogImageUrl={ogImageUrl}
|
||||
>
|
||||
<GuideHeader guide={guide} />
|
||||
|
||||
<div class='mx-auto max-w-[700px] py-5 sm:py-10'>
|
||||
<MarkdownFile>
|
||||
<guide.Content />
|
||||
</MarkdownFile>
|
||||
</div>
|
||||
<GuideHeader guide={guide!} />
|
||||
<GuideContent guide={guide!} />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
import GuideContent from '../../components/Guide/GuideContent.astro';
|
||||
import GuideHeader from '../../components/GuideHeader.astro';
|
||||
import MarkdownFile from '../../components/MarkdownFile.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getGuideById } from '../../lib/guide';
|
||||
import { getOpenGraphImageUrl } from '../../lib/open-graph';
|
||||
|
||||
const guideId = 'backend-languages';
|
||||
const guide = await getGuideById('backend-languages');
|
||||
const guide = await getGuideById(guideId).catch(() => null);
|
||||
if (!guide) {
|
||||
return Astro.redirect('/404');
|
||||
}
|
||||
|
||||
const { frontmatter: guideData } = guide!;
|
||||
|
||||
@@ -24,10 +27,5 @@ const ogImageUrl = getOpenGraphImageUrl({
|
||||
ogImageUrl={ogImageUrl}
|
||||
>
|
||||
<GuideHeader guide={guide!} />
|
||||
|
||||
<div class='mx-auto max-w-[700px] py-5 sm:py-10'>
|
||||
<MarkdownFile>
|
||||
<guide.Content />
|
||||
</MarkdownFile>
|
||||
</div>
|
||||
<GuideContent guide={guide!} />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,13 +7,13 @@ import { TopicDetail } from '../../../components/TopicDetail/TopicDetail';
|
||||
import UpcomingForm from '../../../components/UpcomingForm.astro';
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import { UserProgressModal } from '../../../components/UserProgress/UserProgressModal';
|
||||
import {
|
||||
type BestPracticeFileType,
|
||||
type BestPracticeFrontmatter,
|
||||
getAllBestPractices,
|
||||
} from '../../../lib/best-pratice';
|
||||
import { generateArticleSchema } from '../../../lib/jsonld-schema';
|
||||
import { getOpenGraphImageUrl } from '../../../lib/open-graph';
|
||||
import {
|
||||
BestPracticeFileType,
|
||||
BestPracticeFrontmatter,
|
||||
getAllBestPractices,
|
||||
} from '../../../lib/best-practice';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const bestPractices = await getAllBestPractices();
|
||||
@@ -98,7 +98,12 @@ const ogImageUrl = getOpenGraphImageUrl({
|
||||
pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`}
|
||||
/>
|
||||
|
||||
<TopicDetail client:idle canSubmitContribution={true} />
|
||||
<TopicDetail
|
||||
resourceTitle={bestPracticeData.title}
|
||||
resourceType='best-practice'
|
||||
client:idle
|
||||
canSubmitContribution={true}
|
||||
/>
|
||||
|
||||
<FrameRenderer
|
||||
resourceType={'best-practice'}
|
||||
|
||||
@@ -5,7 +5,7 @@ export async function getStaticPaths() {
|
||||
'/src/data/best-practices/**/*.json',
|
||||
{
|
||||
eager: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return Object.keys(bestPracticeJsons).map((filePath) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import GridItem from '../../components/GridItem.astro';
|
||||
import SimplePageHeader from '../../components/SimplePageHeader.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getAllBestPractices } from '../../lib/best-pratice';
|
||||
import { getAllBestPractices } from '../../lib/best-practice';
|
||||
|
||||
const bestPractices = await getAllBestPractices();
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import GuideContent from '../../components/Guide/GuideContent.astro';
|
||||
import GuideHeader from '../../components/GuideHeader.astro';
|
||||
import MarkdownFile from '../../components/MarkdownFile.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getAllGuides, type GuideFileType } from '../../lib/guide';
|
||||
import { getOpenGraphImageUrl } from '../../lib/open-graph';
|
||||
@@ -38,10 +38,5 @@ const ogImageUrl = getOpenGraphImageUrl({
|
||||
ogImageUrl={ogImageUrl}
|
||||
>
|
||||
<GuideHeader guide={guide} />
|
||||
|
||||
<div class='mx-auto max-w-[700px] py-5 sm:py-10'>
|
||||
<MarkdownFile>
|
||||
<guide.Content />
|
||||
</MarkdownFile>
|
||||
</div>
|
||||
<GuideContent guide={guide!} />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,7 +4,7 @@ import FeaturedGuides from '../components/FeaturedGuides.astro';
|
||||
import FeaturedItems from '../components/FeaturedItems/FeaturedItems.astro';
|
||||
import HeroSection from '../components/HeroSection/HeroSection.astro';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { getAllBestPractices } from '../lib/best-pratice';
|
||||
import { getAllBestPractices } from '../lib/best-practice';
|
||||
import { getAllGuides } from '../lib/guide';
|
||||
import { getRoadmapsByTag } from '../lib/roadmap';
|
||||
import { getAllVideos } from '../lib/video';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user