mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-26 02:25:35 +02:00
feat: profile pages, custom roadmap pages and SSR (#5494)
* Update * Add stats and health endpoints * Add pre-render * fix: redirect to the error page * Fix generate-renderer issue * Rename * Fix best practice topics not loading * Handle SSR for static pages * Refactor faqs * Refactor best practices * Fix absolute import * Fix stats * Add custom roadmap page * Minor UI change * feat: custom roadmap slug routes (#4987) * feat: replace roadmap slug * fix: remove roadmap slug * feat: username route * fix: user public page * feat: show roadmap progress * feat: update public profile * fix: replace with toast * feat: user public profile page * feat: implement profile form * feat: implement user profile roadmap page * refactor: remove logs * fix: increase progress gap * fix: remove title margin * fix: breakpoint for roadmaps * Update dependencies * Upgrade dependencies * fix: improper avatars * fix: heatmap focus * wip: remove `getStaticPaths` * fix: add disable props * wip * feat: add email icon * fix: update pnpm lock * fix: implement author page * Fix beginner roadmaps not working * Changes to form * Refactor profile and form * Refactor public profile form * Rearrange sidebar items * Update UI for public form * Minor text update * Refactor public profile form * Error page for user * Revamp UI for profile page * Add public profile page * Fix vite warnings * Add private profile banner * feat: on blur check username * Update fetch depth * Add error detail * Use hybrid mode of rendering * Do not pre-render stats pages * Update deployment workflow * Update deployment workflow --------- Co-authored-by: Arik Chakma <arikchangma@gmail.com>
This commit is contained in:
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 -apvz --delete --no-times --exclude "configuration" -e "ssh -o StrictHostKeyChecking=no" -p ./ ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}:/var/www/v2.roadmap.sh/
|
||||
- name: Restart PM2
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.EC2_HOST }}
|
||||
username: ${{ secrets.EC2_USERNAME }}
|
||||
key: ${{ secrets.EC2_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /var/www/v2.roadmap.sh
|
||||
sudo pm2 restart web-roadmap
|
||||
|
||||
# --------------------
|
||||
# Clear Cloudfront Caching
|
||||
# --------------------
|
||||
- name: Clear Cloudfront Caching
|
||||
run: |
|
||||
curl -L \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/roadmapsh/infra-ansible/actions/workflows/playbook.yml/dispatches \
|
||||
-d '{ "ref":"master", "inputs": { "playbook": "roadmap_web.yml", "tags": "cloudfront", "is_verbose": false } }'
|
@@ -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
|
154
src/api/api.ts
Normal file
154
src/api/api.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../lib/jwt.ts';
|
||||
import type { APIContext } from 'astro';
|
||||
|
||||
type HttpOptionsType = RequestInit | { headers: Record<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',
|
||||
|
@@ -14,6 +14,7 @@ type ProgressResponse = {
|
||||
done: number;
|
||||
total: number;
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
};
|
||||
|
||||
export type ActivityResponse = {
|
||||
|
@@ -17,6 +17,7 @@ type ResourceProgressType = {
|
||||
onCleared?: () => void;
|
||||
showClearButton?: boolean;
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
@@ -37,6 +38,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
doneCount,
|
||||
skippedCount,
|
||||
onCleared,
|
||||
roadmapSlug,
|
||||
} = props;
|
||||
|
||||
async function clearProgress() {
|
||||
@@ -46,7 +48,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
{
|
||||
resourceId,
|
||||
resourceType,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -72,7 +74,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
: `/best-practices/${resourceId}`;
|
||||
|
||||
if (isCustomResource) {
|
||||
url = `/r?id=${resourceId}`;
|
||||
url = `/r/${roadmapSlug}`;
|
||||
}
|
||||
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
|
@@ -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);
|
||||
|
@@ -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'}
|
||||
>
|
||||
|
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
@@ -20,7 +20,7 @@ export function AccountDropdownList(props: AccountDropdownListProps) {
|
||||
className="group flex items-center gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<User2 className="h-4 w-4 stroke-[2.5px] text-slate-400 group-hover:text-white" />
|
||||
Profile
|
||||
Account
|
||||
</a>
|
||||
</li>
|
||||
<li className="px-1">
|
||||
|
@@ -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,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'
|
||||
}
|
||||
|
87
src/components/UpdateProfile/ProfileUsername.tsx
Normal file
87
src/components/UpdateProfile/ProfileUsername.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { 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';
|
||||
|
||||
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 checkIsUnique = async (username: string) => {
|
||||
if (isLoading || username.length < 3) {
|
||||
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="text-sm leading-none text-slate-500">
|
||||
Username
|
||||
</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"
|
||||
onChange={(e) => setUsername((e.target as HTMLInputElement).value)}
|
||||
onBlur={(e) => checkIsUnique((e.target as HTMLInputElement).value)}
|
||||
required={profileVisibility === 'public'}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
@@ -2,15 +2,13 @@ import { type FormEvent, useEffect, useState } from 'react';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import UploadProfilePicture from './UploadProfilePicture';
|
||||
import {ArrowDown, ChevronDown} from "lucide-react";
|
||||
|
||||
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 +24,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 +52,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 +72,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 +106,17 @@ 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-purple-700 text-xs underline hover:text-purple-800">
|
||||
Visit settings page to change email
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
@@ -131,77 +129,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 +144,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>
|
||||
|
505
src/components/UpdateProfile/UpdatePublicProfileForm.tsx
Normal file
505
src/components/UpdateProfile/UpdatePublicProfileForm.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react';
|
||||
import { httpGet, httpPatch } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type {
|
||||
AllowedCustomRoadmapVisibility,
|
||||
AllowedProfileVisibility,
|
||||
AllowedRoadmapVisibility,
|
||||
UserDocument,
|
||||
} from '../../api/user';
|
||||
import { SelectionButton } from '../RoadCard/SelectionButton';
|
||||
import { ArrowUpRight, Eye, EyeOff } from 'lucide-react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
import { VisibilityDropdown } from './VisibilityDropdown.tsx';
|
||||
import { ProfileUsername } from './ProfileUsername.tsx';
|
||||
|
||||
type RoadmapType = {
|
||||
id: string;
|
||||
title: string;
|
||||
isCustomResource: boolean;
|
||||
};
|
||||
|
||||
type GetProfileSettingsResponse = Pick<
|
||||
UserDocument,
|
||||
'username' | 'profileVisibility' | 'publicConfig' | 'links'
|
||||
>;
|
||||
|
||||
export function UpdatePublicProfileForm() {
|
||||
const [profileVisibility, setProfileVisibility] =
|
||||
useState<AllowedProfileVisibility>('private');
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
const [publicProfileUrl, setPublicProfileUrl] = useState('');
|
||||
const [isAvailableForHire, setIsAvailableForHire] = useState(false);
|
||||
const [isEmailVisible, setIsEmailVisible] = useState(true);
|
||||
const [headline, setHeadline] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [roadmapVisibility, setRoadmapVisibility] =
|
||||
useState<AllowedRoadmapVisibility>('all');
|
||||
const [customRoadmapVisibility, setCustomRoadmapVisibility] =
|
||||
useState<AllowedCustomRoadmapVisibility>('all');
|
||||
const [roadmaps, setRoadmaps] = useState<string[]>([]);
|
||||
const [customRoadmaps, setCustomRoadmaps] = useState<string[]>([]);
|
||||
|
||||
const [currentUsername, setCurrentUsername] = 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 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,
|
||||
},
|
||||
);
|
||||
|
||||
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 {
|
||||
links,
|
||||
username,
|
||||
profileVisibility: defaultProfileVisibility,
|
||||
publicConfig,
|
||||
} = response;
|
||||
|
||||
setPublicProfileUrl(username ? `/u/${username}` : '');
|
||||
setUsername(username || '');
|
||||
setCurrentUsername(username || '');
|
||||
setGithub(links?.github || '');
|
||||
setTwitter(links?.twitter || '');
|
||||
setLinkedin(links?.linkedin || '');
|
||||
setWebsite(links?.website || '');
|
||||
setProfileVisibility(defaultProfileVisibility || 'private');
|
||||
setHeadline(publicConfig?.headline || '');
|
||||
setRoadmapVisibility(publicConfig?.roadmapVisibility || 'none');
|
||||
setCustomRoadmapVisibility(publicConfig?.customRoadmapVisibility || 'none');
|
||||
setCustomRoadmaps(publicConfig?.customRoadmaps || []);
|
||||
setRoadmaps(publicConfig?.roadmaps || []);
|
||||
setCustomRoadmapVisibility(publicConfig?.customRoadmapVisibility || 'none');
|
||||
setIsAvailableForHire(publicConfig?.isAvailableForHire || false);
|
||||
setIsEmailVisible(publicConfig?.isEmailVisible ?? true);
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const loadProfileRoadmaps = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { error, response } = await httpGet<{
|
||||
roadmaps: RoadmapType[];
|
||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-profile-roadmaps`);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileRoadmaps(response?.roadmaps || []);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const updateProfileVisibility = async (
|
||||
visibility: AllowedProfileVisibility,
|
||||
) => {
|
||||
pageProgressMessage.set('Updating profile visibility');
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-public-profile-visibility`,
|
||||
{
|
||||
profileVisibility: visibility,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
toast.error(error.message || 'Something went wrong');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileVisibility(visibility);
|
||||
setIsLoading(false);
|
||||
pageProgressMessage.set('');
|
||||
};
|
||||
|
||||
// Make a request to the backend to fill in the form with the current values
|
||||
useEffect(() => {
|
||||
Promise.all([loadProfileSettings(), loadProfileRoadmaps()]).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const publicCustomRoadmaps = profileRoadmaps.filter(
|
||||
(r) => r.isCustomResource,
|
||||
);
|
||||
const publicRoadmaps = profileRoadmaps.filter((r) => !r.isCustomResource);
|
||||
|
||||
return (
|
||||
<div className="-mx-10 mt-10 border-t px-10 pt-10">
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex flex-col justify-between gap-2 sm:flex-row">
|
||||
<div className="flex flex-grow flex-col items-start gap-2 sm:flex-row">
|
||||
<h3 className="mr-1 text-xl font-bold sm:text-3xl">
|
||||
Personal Profile
|
||||
</h3>
|
||||
{publicProfileUrl && (
|
||||
<a
|
||||
href={publicProfileUrl}
|
||||
target="_blank"
|
||||
className="flex h-[30px] shrink-0 flex-row items-center gap-1 rounded-lg border border-black pl-1.5 pr-2.5 text-sm transition-colors hover:bg-black hover:text-white"
|
||||
>
|
||||
<ArrowUpRight className="h-3 w-3 stroke-[3]" />
|
||||
Visit
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<VisibilityDropdown
|
||||
visibility={profileVisibility}
|
||||
setVisibility={setProfileVisibility}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 hidden text-sm text-gray-400 sm:mt-0 sm:block sm:text-base">
|
||||
Set up your public profile to showcase your learning progress.
|
||||
</p>
|
||||
|
||||
<form className="mt-6 space-y-4 pb-10" onSubmit={handleSubmit}>
|
||||
<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-2 hover:bg-gray-100">
|
||||
<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"
|
||||
>
|
||||
Make my email public
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex select-none items-center gap-2 rounded-md border px-2 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...' : 'Update Public 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>
|
||||
);
|
||||
}
|
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>
|
||||
);
|
||||
}
|
112
src/components/UserPublicProfile/UserPublicProgresses.tsx
Normal file
112
src/components/UserPublicProfile/UserPublicProgresses.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { GetPublicProfileResponse } from '../../api/user';
|
||||
import { UserPublicProgressStats } from './UserPublicProgressStats';
|
||||
import { getPercentage } from '../../helper/number.ts';
|
||||
|
||||
type UserPublicProgressesProps = {
|
||||
userId: string;
|
||||
username: string;
|
||||
roadmaps: GetPublicProfileResponse['roadmaps'];
|
||||
publicConfig: GetPublicProfileResponse['publicConfig'];
|
||||
};
|
||||
|
||||
export function UserPublicProgresses(props: UserPublicProgressesProps) {
|
||||
const {
|
||||
roadmaps: roadmapProgresses = [],
|
||||
username,
|
||||
publicConfig,
|
||||
userId,
|
||||
} = props;
|
||||
const { roadmapVisibility, customRoadmapVisibility } = publicConfig! || {};
|
||||
|
||||
const roadmaps = roadmapProgresses.filter(
|
||||
(roadmap) => !roadmap.isCustomResource,
|
||||
);
|
||||
const customRoadmaps = roadmapProgresses.filter(
|
||||
(roadmap) => roadmap.isCustomResource,
|
||||
);
|
||||
|
||||
// <UserPublicProgressStats
|
||||
// updatedAt={roadmap.updatedAt}
|
||||
// title={roadmap.title}
|
||||
// totalCount={roadmap.total}
|
||||
// doneCount={roadmap.done}
|
||||
// learningCount={roadmap.learning}
|
||||
// skippedCount={roadmap.skipped}
|
||||
// resourceId={roadmap.id}
|
||||
// resourceType="roadmap"
|
||||
// roadmapSlug={roadmap.roadmapSlug}
|
||||
// username={username!}
|
||||
// isCustomResource={true}
|
||||
// userId={userId}
|
||||
// />
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import type { FAQType } from '../../components/FAQs/FAQs.astro';
|
||||
import type { FAQType } from '../../../components/FAQs/FAQs.astro';
|
||||
|
||||
export const faqs: FAQType[] = [
|
||||
{
|
||||
|
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> = {};
|
||||
|
@@ -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',
|
||||
});
|
||||
}
|
||||
|
@@ -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),
|
||||
};
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import FAQs from '../../components/FAQs/FAQs.astro';
|
||||
import FAQs, { type FAQType } from '../../components/FAQs/FAQs.astro';
|
||||
import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro';
|
||||
import MarkdownFile from '../../components/MarkdownFile.astro';
|
||||
import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro';
|
||||
import RoadmapHeader from '../../components/RoadmapHeader.astro';
|
||||
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
|
||||
@@ -54,7 +53,7 @@ if (roadmapData.schema) {
|
||||
}
|
||||
|
||||
if (roadmapFAQs.length) {
|
||||
jsonLdSchema.push(generateFAQSchema(roadmapFAQs));
|
||||
jsonLdSchema.push(generateFAQSchema(roadmapFAQs as unknown as FAQType[]));
|
||||
}
|
||||
|
||||
const ogImageUrl =
|
||||
@@ -125,7 +124,7 @@ const ogImageUrl =
|
||||
client:only='react'
|
||||
/>
|
||||
|
||||
<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,7 @@
|
||||
---
|
||||
import AccountSidebar from '../../components/AccountSidebar.astro';
|
||||
import { UpdateProfileForm } from '../../components/UpdateProfile/UpdateProfileForm';
|
||||
import { UpdatePublicProfileForm } from '../../components/UpdateProfile/UpdatePublicProfileForm';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
@@ -11,5 +12,6 @@ import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
>
|
||||
<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();
|
||||
|
@@ -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';
|
||||
|
@@ -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>
|
61
src/pages/u/[username].astro
Normal file
61
src/pages/u/[username].astro
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
import { FrownIcon } from 'lucide-react';
|
||||
import { userApi } from '../../api/user';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
import { UserPublicProfilePage } from '../../components/UserPublicProfile/UserPublicProfilePage';
|
||||
import OpenSourceBanner from '../../components/OpenSourceBanner.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
interface Params extends Record<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';
|
||||
}
|
||||
---
|
||||
|
||||
<AccountLayout title={userDetails?.name} errorMessage={errorMessage}>
|
||||
{!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>
|
||||
)
|
||||
}
|
||||
|
||||
<OpenSourceBanner />
|
||||
<Footer />
|
||||
</AccountLayout>
|
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 = false;
|
||||
|
||||
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