mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-13 02:01:57 +08:00
Compare commits
33 Commits
| 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 |
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 } }'
|
||||
72
.github/workflows/rsync-ssr.yml
vendored
72
.github/workflows/rsync-ssr.yml
vendored
@@ -1,72 +0,0 @@
|
||||
name: Deploy to EC2
|
||||
on:
|
||||
workflow_dispatch: # allow manual run
|
||||
push:
|
||||
branches:
|
||||
- feat/ssr
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 20
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 8.15.6
|
||||
|
||||
# --------------------
|
||||
# Setup configuration
|
||||
# --------------------
|
||||
- name: Prepare configuration files
|
||||
run: |
|
||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/infra-config.git configuration --depth 1
|
||||
- name: Copy configuration files
|
||||
run: |
|
||||
cp configuration/dist/github/developer-roadmap.env .env
|
||||
|
||||
# --------------------
|
||||
# Prepare the build
|
||||
# --------------------
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Generate build
|
||||
run: |
|
||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
|
||||
npm run generate-renderer
|
||||
npm run build
|
||||
|
||||
# --------------------
|
||||
# Deploy to EC2
|
||||
# --------------------
|
||||
- uses: webfactory/ssh-agent@v0.7.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.EC2_PRIVATE_KEY }}
|
||||
- name: Deploy app to EC2
|
||||
run: |
|
||||
rsync -avz --omit-dir-times --exclude ".git" --exclude "configuration" -e "ssh -o StrictHostKeyChecking=no" -p ./ ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}:/var/www/v2.roadmap.sh/
|
||||
- name: Restart PM2
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.EC2_HOST }}
|
||||
username: ${{ secrets.EC2_USERNAME }}
|
||||
key: ${{ secrets.EC2_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /var/www/v2.roadmap.sh
|
||||
sudo pm2 restart web-roadmap
|
||||
|
||||
# --------------------
|
||||
# Clear Cloudfront Caching
|
||||
# --------------------
|
||||
- name: Clear Cloudfront Caching
|
||||
run: |
|
||||
curl -L \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/roadmapsh/infra-ansible/actions/workflows/playbook.yml/dispatches \
|
||||
-d '{ "ref":"master", "inputs": { "playbook": "roadmap_web.yml", "tags": "cloudfront", "is_verbose": false } }'
|
||||
@@ -1,10 +1,10 @@
|
||||
// https://astro.build/config
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import node from '@astrojs/node';
|
||||
import compress from 'astro-compress';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import rehypeExternalLinks from 'rehype-external-links';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
|
||||
|
||||
import react from '@astrojs/react';
|
||||
@@ -41,9 +41,11 @@ export default defineConfig({
|
||||
],
|
||||
],
|
||||
},
|
||||
build: {
|
||||
format: 'file',
|
||||
},
|
||||
output: 'hybrid',
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
trailingSlash: 'never',
|
||||
integrations: [
|
||||
tailwind({
|
||||
config: {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^8.2.1",
|
||||
"@astrojs/react": "^3.0.10",
|
||||
"@astrojs/sitemap": "^3.0.5",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
@@ -34,6 +35,7 @@
|
||||
"astro": "^4.4.0",
|
||||
"astro-compress": "^2.2.10",
|
||||
"clsx": "^2.1.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"dracula-prism": "^2.1.16",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -48,8 +50,10 @@
|
||||
"npm-check-updates": "^16.14.15",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-calendar-heatmap": "^1.9.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-tooltip": "^5.26.3",
|
||||
"reactflow": "^11.10.4",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
@@ -69,6 +73,7 @@
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react-calendar-heatmap": "^1.6.7",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^6.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
||||
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 || [];
|
||||
|
||||
@@ -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,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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import AstroIcon from '../../components/AstroIcon.astro';
|
||||
import { getGuidesByAuthor } from '../../lib/guide';
|
||||
import { getAllVideos, getVideosByAuthor } from '../../lib/video';
|
||||
import { getVideosByAuthor } from '../../lib/video';
|
||||
import GuideListItem from '../../components/GuideListItem.astro';
|
||||
import { getAuthorById, getAuthorIds } from '../../lib/author';
|
||||
import VideoListItem from '../../components/VideoListItem.astro';
|
||||
|
||||
@@ -6,7 +6,10 @@ import { getGuideById } from '../../lib/guide';
|
||||
import { getOpenGraphImageUrl } from '../../lib/open-graph';
|
||||
|
||||
const guideId = 'backend-languages';
|
||||
const guide = await getGuideById('backend-languages');
|
||||
const guide = await getGuideById(guideId).catch(() => null);
|
||||
if (!guide) {
|
||||
return Astro.redirect('/404');
|
||||
}
|
||||
|
||||
const { frontmatter: guideData } = guide!;
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ import { TopicDetail } from '../../../components/TopicDetail/TopicDetail';
|
||||
import UpcomingForm from '../../../components/UpcomingForm.astro';
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import { UserProgressModal } from '../../../components/UserProgress/UserProgressModal';
|
||||
import {
|
||||
type BestPracticeFileType,
|
||||
type BestPracticeFrontmatter,
|
||||
getAllBestPractices,
|
||||
} from '../../../lib/best-pratice';
|
||||
import { generateArticleSchema } from '../../../lib/jsonld-schema';
|
||||
import { getOpenGraphImageUrl } from '../../../lib/open-graph';
|
||||
import {
|
||||
BestPracticeFileType,
|
||||
BestPracticeFrontmatter,
|
||||
getAllBestPractices,
|
||||
} from '../../../lib/best-practice';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const bestPractices = await getAllBestPractices();
|
||||
@@ -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();
|
||||
---
|
||||
|
||||
@@ -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';
|
||||
|
||||
42
src/pages/og/[slug].ts
Normal file
42
src/pages/og/[slug].ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDefaultOpenGraphImageBuffer } from '../../lib/open-graph';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
type Params = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export const GET: APIRoute<any, Params> = async (context) => {
|
||||
const { slug } = context.params;
|
||||
|
||||
if (!slug.startsWith('user-')) {
|
||||
const buffer = await getDefaultOpenGraphImageBuffer();
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const username = slug.replace('user-', '');
|
||||
if (!username) {
|
||||
const buffer = await getDefaultOpenGraphImageBuffer();
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-profile-open-graph/${username}`,
|
||||
);
|
||||
|
||||
const svg = await response.text();
|
||||
return new Response(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAllBestPractices } from '../lib/best-pratice';
|
||||
import { getAllBestPractices } from '../lib/best-practice';
|
||||
import { getAllGuides } from '../lib/guide';
|
||||
import { getRoadmapsByTag } from '../lib/roadmap';
|
||||
import { getAllVideos } from '../lib/video';
|
||||
|
||||
@@ -38,11 +38,11 @@ const { frontmatter } = questionGroup;
|
||||
>
|
||||
<div class='flex bg-gray-50 pb-14 pt-4 sm:pb-16 sm:pt-8'>
|
||||
<div class='container !max-w-[740px]'>
|
||||
<div class='mb-3 sm:mb-5 mt-2 text-left sm:text-center sm:mt-8'>
|
||||
<div class='mb-3 mt-2 text-left sm:mb-5 sm:mt-8 sm:text-center'>
|
||||
<div class='mb-2 md:mb-6'>
|
||||
<a
|
||||
href='/questions'
|
||||
class='group rounded-md text-sm font-medium text-gray-400 hover:text-gray-800 transition-colors duration-200'
|
||||
class='group rounded-md text-sm font-medium text-gray-400 transition-colors duration-200 hover:text-gray-800'
|
||||
>
|
||||
<span
|
||||
class='inline-block transform transition-transform group-hover:translate-x-[-2px]'
|
||||
@@ -55,7 +55,7 @@ const { frontmatter } = questionGroup;
|
||||
<h1 class='mb-1 text-2xl font-bold sm:mb-5 sm:text-5xl'>
|
||||
{frontmatter.title}
|
||||
</h1>
|
||||
<p class='hidden sm:block text-xl text-gray-500'>
|
||||
<p class='hidden text-xl text-gray-500 sm:block'>
|
||||
{frontmatter.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
26
src/pages/r/[customRoadmapSlug].astro
Normal file
26
src/pages/r/[customRoadmapSlug].astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { CustomRoadmap } from '../../components/CustomRoadmap/CustomRoadmap';
|
||||
import { SkeletonRoadmapHeader } from '../../components/CustomRoadmap/SkeletonRoadmapHeader';
|
||||
import Loader from '../../components/Loader.astro';
|
||||
import ProgressHelpPopup from '../../components/ProgressHelpPopup.astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const { customRoadmapSlug } = Astro.params;
|
||||
---
|
||||
|
||||
<BaseLayout title='Roadmaps'>
|
||||
<ProgressHelpPopup />
|
||||
<div>
|
||||
<div class='flex min-h-[550px] flex-col'>
|
||||
<div data-roadmap-loader class='flex w-full grow flex-col'>
|
||||
<SkeletonRoadmapHeader />
|
||||
<div class='flex grow items-center justify-center'>
|
||||
<Loader />
|
||||
</div>
|
||||
</div>
|
||||
<CustomRoadmap slug={customRoadmapSlug} client:only='react' />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
62
src/pages/u/[username].astro
Normal file
62
src/pages/u/[username].astro
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import { userApi } from '../../api/user';
|
||||
import { UserPublicProfilePage } from '../../components/UserPublicProfile/UserPublicProfilePage';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
interface Params extends Record<string, string | undefined> {
|
||||
username: string;
|
||||
}
|
||||
|
||||
const { username } = Astro.params as Params;
|
||||
if (!username) {
|
||||
return Astro.redirect('/404');
|
||||
}
|
||||
|
||||
const userClient = userApi(Astro as any);
|
||||
const { response: userDetails, error } =
|
||||
await userClient.getPublicProfile(username);
|
||||
|
||||
let errorMessage = '';
|
||||
if (error || !userDetails) {
|
||||
errorMessage = error?.message || 'User not found';
|
||||
}
|
||||
|
||||
const origin = Astro.url.origin;
|
||||
const ogImage = `${origin}/og/user-${username}`;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${userDetails?.name || 'Unknown'} - Skill Profile at roadmap.sh`}
|
||||
description='Check out my skill profile at roadmap.sh'
|
||||
ogImageUrl={ogImage}
|
||||
>
|
||||
{!errorMessage && <UserPublicProfilePage {...userDetails!} client:load />}
|
||||
{
|
||||
errorMessage && (
|
||||
<div class='container my-24 flex flex-col'>
|
||||
<picture>
|
||||
<source
|
||||
srcset='https://fonts.gstatic.com/s/e/notoemoji/latest/1f61e/512.webp'
|
||||
type='image/webp'
|
||||
/>
|
||||
<img
|
||||
src='https://fonts.gstatic.com/s/e/notoemoji/latest/1f61e/512.gif'
|
||||
alt='😞'
|
||||
width='120'
|
||||
height='120'
|
||||
/>
|
||||
</picture>
|
||||
<h2 class='my-2 text-2xl font-bold sm:my-3 sm:text-4xl'>
|
||||
Problem loading user!
|
||||
</h2>
|
||||
<p class='text-lg'>
|
||||
<span class='rounded-md bg-red-600 px-2 py-1 text-white'>
|
||||
{errorMessage}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</BaseLayout>
|
||||
7
src/pages/v1-health.ts
Normal file
7
src/pages/v1-health.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export async function GET() {
|
||||
return new Response(JSON.stringify({}), {});
|
||||
}
|
||||
33
src/pages/v1-stats.json.ts
Normal file
33
src/pages/v1-stats.json.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export async function GET() {
|
||||
const commitHash = execSync('git rev-parse HEAD').toString().trim();
|
||||
const commitDate = execSync('git log -1 --format=%cd').toString().trim();
|
||||
const commitMessage = execSync('git log -1 --format=%B').toString().trim();
|
||||
|
||||
const prevCommitHash = execSync('git rev-parse HEAD~1').toString().trim();
|
||||
const prevCommitDate = execSync('git log -1 --format=%cd HEAD~1')
|
||||
.toString()
|
||||
.trim();
|
||||
const prevCommitMessage = execSync('git log -1 --format=%B HEAD~1')
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
current: {
|
||||
hash: commitHash,
|
||||
date: commitDate,
|
||||
message: commitMessage,
|
||||
},
|
||||
previous: {
|
||||
hash: prevCommitHash,
|
||||
date: prevCommitDate,
|
||||
message: prevCommitMessage,
|
||||
},
|
||||
}),
|
||||
{},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import VideoHeader from '../../components/VideoHeader.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getAllVideos,VideoFileType } from '../../lib/video';
|
||||
import { getAllVideos, VideoFileType } from '../../lib/video';
|
||||
|
||||
export interface Props {
|
||||
video: VideoFileType;
|
||||
@@ -29,7 +29,7 @@ const { video } = Astro.props;
|
||||
|
||||
<div class='bg-gray-50 py-5 sm:py-10'>
|
||||
<div
|
||||
class='container prose prose-code:bg-transparent prose-h2:text-3xl prose-h2:mt-4 prose-h2:mb-2 prose-h3:mt-2 prose-img:mt-1'
|
||||
class='container prose prose-h2:mb-2 prose-h2:mt-4 prose-h2:text-3xl prose-h3:mt-2 prose-code:bg-transparent prose-img:mt-1'
|
||||
>
|
||||
<video.Content />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user