Compare commits

...

48 Commits

Author SHA1 Message Date
Arik Chakma
9e93e7cbe2 wip 2024-04-24 21:55:26 +06:00
Abhishek
fd46f026dd Fix the content of IF function in data analyst roadmap (#5547) 2024-04-24 15:04:26 +01:00
Kamran Ahmed
41b179fc66 Change path for raycast app launcher image 2024-04-24 00:06:37 +01:00
Kamran Ahmed
5f96fac3cc Update topic detail text 2024-04-23 23:08:01 +01:00
Arik Chakma
39fc4cb502 feat: add user open graph (#5543)
* feat: add user open graph

* fix: add proxy for open graph
2024-04-23 23:07:15 +01:00
Kamran Ahmed
fac9a2bd6a Add google youtube links in topic content 2024-04-23 17:35:29 +01:00
Kamran Ahmed
2bc6d16f3f Text change 2024-04-22 18:11:33 +01:00
Kamran Ahmed
a77b0eefd0 Space alignment 2024-04-22 17:42:17 +01:00
Arik Chakma
dccbe683fd feat: implement activity stream (#5485)
* wip: implement activity stream

* feat: add empty stream

* fix: filter empty topic ids

* fix: update progress group

* fix: update icon

* feat: add topic titles

* fix: update topic title

* fix: update http call

* Redesign activity stream

* Add activity stream

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2024-04-22 17:19:34 +01:00
Ivan Vasilev
4db353e017 Replace plagiarized source with original (#5539)
Remove a plagiarized article and refer to the original article and youtube video by Fireship
2024-04-22 11:13:23 +01:00
Cinquin Andy
91dc6b862c Fix broken video link, and add unlighthouse to the ressources (#5527)
* Update 112-lighthouse.md

* fix: broken youtube link

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
2024-04-22 10:53:12 +06:00
Kamran Ahmed
d2efad25a8 Add copy button 2024-04-20 14:07:10 +01:00
Kamran Ahmed
4db5cc920b Add copy button 2024-04-20 14:06:53 +01:00
Kamran Ahmed
eef734abfd Add alert for skill profile 2024-04-20 13:06:27 +01:00
Kamran Ahmed
8e252cc062 Update profile form 2024-04-20 01:54:30 +01:00
Kamran Ahmed
abf33b8f47 Update profile form 2024-04-20 01:40:41 +01:00
Kamran Ahmed
e1a9dcc511 Add my profile 2024-04-19 18:03:37 +01:00
Kamran Ahmed
3cf246cc31 Add profile button in navigation 2024-04-19 17:43:32 +01:00
Kamran Ahmed
f9e90bfda5 Validation on the username 2024-04-19 17:32:53 +01:00
Selva Muthu Kumaran Boopalan
d042f4511c Add linux upskill challenge link (#4554)
devops-roadmap-ubuntu-linux-upskill-challenge link fixed
fix : #4550
2024-04-19 14:21:02 +01:00
Mark-a-obrien
b2adb619f0 Fix grammatical mistake (#5503)
* Update react.md - fixed grammatical error

Changed 
- "Server Components in allow developers to write components"
to 
- "Server Components allows developers to write components"

* Update src/data/question-groups/react/react.md

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2024-04-16 15:24:51 +01:00
Zakarya Meddahi
3ab6942cee Fix typo in ephemeral FS in docker
Solve conflict in information between lines 3 and 9
"stopped (and) removed" in line 3
"stopped (or) removed" in line 9
2024-04-16 15:23:57 +01:00
Arik Chakma
a20400f6a2 feat: implement sponsor (#5480)
* feat: implement sponsor

* wip: add view cookie

* Update src/lib/jwt.ts

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2024-04-15 13:16:55 +01:00
Kamran Ahmed
285f2c05f2 Add skill profile to indexing 2024-04-14 12:21:46 +01:00
Kamran Ahmed
e716007245 Replace h1 tags with h2 2024-04-12 18:46:51 +01:00
Kamran Ahmed
c2a5e5a805 Make API call on 3 or more characters 2024-04-12 00:03:14 +01:00
Kamran Ahmed
c4c7499a22 Pre-render stats page 2024-04-11 23:10:14 +01:00
Kamran Ahmed
ad6002a514 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>
2024-04-11 22:35:52 +01:00
Kamran Ahmed
b029eebd7b Delete in rsync 2024-04-10 09:52:33 +01:00
Kamran Ahmed
bacf0e6320 Fix vite warnings 2024-04-10 03:28:23 +01:00
Kamran Ahmed
64ed4b6c23 Update rsync 2024-04-10 03:23:10 +01:00
Kamran Ahmed
5da5f41a8d Update rsync 2024-04-10 03:09:43 +01:00
Arik Chakma
ed6f4b64a6 fix: account activity title (#5483) 2024-04-09 23:10:51 +01:00
Kamran Ahmed
c768cac62f Add cloudfront cache clearing 2024-04-09 13:26:07 +01:00
Kamran Ahmed
3ad7765658 Update rsync options 2024-04-08 05:43:36 +01:00
Kamran Ahmed
0e0a70fcbd Exclude .git 2024-04-08 05:31:30 +01:00
Kamran Ahmed
5c330094f9 Fix broken build 2024-04-08 04:02:26 +01:00
Kamran Ahmed
e9f5462180 Fix broken build 2024-04-08 03:48:28 +01:00
Kamran Ahmed
c9534b30f5 Fix broken build 2024-04-08 03:43:43 +01:00
Kamran Ahmed
8537698d91 Update dependencies in github actions 2024-04-08 03:31:14 +01:00
Kamran Ahmed
9414089714 Add rsync script for deployment 2024-04-08 03:26:14 +01:00
Kamran Ahmed
25965d91a0 Replace hiring with AI button 2024-04-08 02:22:19 +01:00
Ethan Cui
7ce02300de fix: typo Chatty I/O (#5471)
The content's title should be "Chatty I/O" instead of "Chat I/O"
2024-04-07 13:06:02 +06:00
Kamran Ahmed
f0859628f7 Reduce table of content gap 2024-04-04 17:02:37 +01:00
Kamran Ahmed
ff94ea7116 Sticky sidebar 2024-04-04 17:01:39 +01:00
Arik Chakma
abad548961 feat: add footer for ai generated roadmaps (#5461)
* feat: add footer for ai generated roadmaps

* fix: add layout in explore page
2024-04-03 20:57:50 +01:00
Arik Chakma
6e81855645 fix: text wrap in AI search roadmap (#5462) 2024-04-03 19:20:38 +01:00
Kamran Ahmed
60568caff7 Add table of contents to guides 2024-04-03 17:25:23 +01:00
108 changed files with 3528 additions and 575 deletions

View File

@@ -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 } }'

View File

@@ -1,10 +1,10 @@
// https://astro.build/config
import sitemap from '@astrojs/sitemap';
import tailwind from '@astrojs/tailwind';
import node from '@astrojs/node';
import compress from 'astro-compress';
import { defineConfig } from 'astro/config';
import rehypeExternalLinks from 'rehype-external-links';
import { fileURLToPath } from 'node:url';
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
import react from '@astrojs/react';
@@ -41,9 +41,11 @@ export default defineConfig({
],
],
},
build: {
format: 'file',
},
output: 'hybrid',
adapter: node({
mode: 'standalone',
}),
trailingSlash: 'never',
integrations: [
tailwind({
config: {

View File

@@ -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
View File

@@ -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

View File

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

153
src/api/api.ts Normal file
View File

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

124
src/api/user.ts Normal file
View 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,
},
);
},
};
}

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
)}

View File

@@ -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" />

View File

@@ -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}

View File

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

View File

@@ -18,7 +18,7 @@ import { PersonalRoadmapActionDropdown } from './PersonalRoadmapActionDropdown';
import type { GetRoadmapListResponse } from './RoadmapListPage';
import { useState, type Dispatch, type SetStateAction } from 'react';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
import {RoadmapIcon} from "../ReactIcons/RoadmapIcon.tsx";
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon.tsx';
type PersonalRoadmapListType = {
roadmaps: GetRoadmapListResponse['personalRoadmaps'];
@@ -37,7 +37,7 @@ export function PersonalRoadmapList(props: PersonalRoadmapListType) {
async function deleteRoadmap(roadmapId: string) {
const { response, error } = await httpDelete<RoadmapDocument[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-roadmap/${roadmapId}`
`${import.meta.env.PUBLIC_API_URL}/v1-delete-roadmap/${roadmapId}`,
);
if (error || !response) {
@@ -61,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'}
>

View File

@@ -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

View File

@@ -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}

View File

@@ -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'}
>

View File

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

View File

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

View File

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

View File

@@ -113,7 +113,9 @@ export function GenerateRoadmap() {
const [roadmapTopicLimitUsed, setRoadmapTopicLimitUsed] = useState(0);
const [isConfiguring, setIsConfiguring] = useState(false);
const [openAPIKey, setOpenAPIKey] = useState<string | undefined>(getOpenAIKey());
const [openAPIKey, setOpenAPIKey] = useState<string | undefined>(
getOpenAIKey(),
);
const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION;
const isAuthenticatedUser = isLoggedIn();
@@ -658,7 +660,7 @@ export function GenerateRoadmap() {
</div>
<div
className={cn({
'relative mb-20 max-h-[800px] min-h-[800px] sm:max-h-[1000px] md:min-h-[1000px] lg:max-h-[1200px] lg:min-h-[1200px] overflow-hidden':
'relative mb-20 max-h-[800px] min-h-[800px] overflow-hidden sm:max-h-[1000px] md:min-h-[1000px] lg:max-h-[1200px] lg:min-h-[1200px]':
!isAuthenticatedUser,
})}
>
@@ -666,18 +668,18 @@ export function GenerateRoadmap() {
ref={roadmapContainerRef}
id="roadmap-container"
onClick={handleNodeClick}
className="relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
className="relative min-h-[400px] px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
/>
{!isAuthenticatedUser && (
<div className="absolute bottom-0 left-0 right-0">
<div className="h-80 w-full bg-gradient-to-t from-gray-100 to-transparent" />
<div className="bg-gray-100">
<div className="mx-auto px-5 max-w-[600px] flex-col items-center justify-center bg-gray-100 pt-px">
<div className="mx-auto max-w-[600px] flex-col items-center justify-center bg-gray-100 px-5 pt-px">
<div className="mt-8 text-center">
<h2 className="mb-0.5 sm:mb-3 text-xl sm:text-2xl font-medium">
<h2 className="mb-0.5 text-xl font-medium sm:mb-3 sm:text-2xl">
Sign up to View the full roadmap
</h2>
<p className="mb-6 text-sm sm:text-base text-gray-600 text-balance">
<p className="mb-6 text-balance text-sm text-gray-600 sm:text-base">
You must be logged in to view the complete roadmap
</p>
</div>

View File

@@ -45,7 +45,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
return (
<div className="flex flex-grow flex-col items-center px-4 py-6 sm:px-6">
<div className="flex flex-grow flex-col items-center px-4 py-6 sm:px-6 md:my-24 lg:my-32">
{isConfiguring && (
<IncreaseRoadmapLimit
onClose={() => {
@@ -55,7 +55,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
}}
/>
)}
<div className="flex flex-col gap-0 text-center sm:gap-2 md:mt-24 lg:mt-32">
<div className="flex flex-col gap-0 text-center sm:gap-2">
<h1 className="relative text-2xl font-medium sm:text-3xl">
<span className="hidden sm:inline">Generate roadmaps with AI</span>
<span className="inline sm:hidden">AI Roadmap Generator</span>

View File

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

View File

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

View File

@@ -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

View File

@@ -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}
/>
);

View File

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

View File

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

View File

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

View File

@@ -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,
},

View File

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

View File

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

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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}
>

View File

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

View File

@@ -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 (

View File

@@ -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,
});

View File

@@ -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'
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View 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;
}

View 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">&middot;</span>
<span>
<span>{learning.length}</span> in progress
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span>{skipped.length}</span> skipped
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span>{topicCount}</span> Total
</span>
</span>
<span className="md:hidden">
<span>{totalMarked}</span> of <span>{topicCount}</span> Done
</span>
</p>
</div>
<UserProfileRoadmapRenderer {...props} resourceType="roadmap" />
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

9
src/helper/number.ts Normal file
View 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);
}

View File

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

View File

@@ -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',
});
}

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ export async function getAllLinkGroups(): Promise<LinkGroupFileType[]> {
'/src/data/link-groups/*.md',
{
eager: true,
}
},
);
return Object.values(linkGroups).map((linkGroupFile) => ({
@@ -37,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),
};
}

View File

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

View 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 }[]> {

View File

@@ -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 || [];
}

View File

@@ -1,8 +1,8 @@
import type { MarkdownFileType } from './file';
import type { AuthorFileType } from './author.ts';
import { getAllAuthors } from './author.ts';
import type {GuideFileType} from "./guide.ts";
import {getAllGuides} from "./guide.ts";
import type { GuideFileType } from './guide.ts';
import { getAllGuides } from './guide.ts';
export interface VideoFrontmatter {
title: string;
@@ -40,7 +40,7 @@ function videoPathToId(filePath: string): string {
}
export async function getVideosByAuthor(
authorId: string,
authorId: string,
): Promise<VideoFileType[]> {
const allVideos = await getAllVideos();
@@ -73,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),
};
}

View File

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

View File

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

View File

@@ -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,
});

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
---
import LoginPopup from '../../components/AuthenticationFlow/LoginPopup.astro';
import { ExploreAIRoadmap } from '../../components/ExploreAIRoadmap/ExploreAIRoadmap';
import AccountLayout from '../../layouts/AccountLayout.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
---
<AccountLayout title='Explore AI Generated Roadmaps'>
<BaseLayout title='Explore AI Generated Roadmaps'>
<ExploreAIRoadmap client:load />
</AccountLayout>
</BaseLayout>

View File

@@ -1,10 +1,8 @@
---
import LoginPopup from '../../components/AuthenticationFlow/LoginPopup.astro';
import { GenerateRoadmap } from '../../components/GenerateRoadmap/GenerateRoadmap';
import AccountLayout from '../../layouts/AccountLayout.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
---
<AccountLayout title='Roadmap AI'>
<BaseLayout title='Roadmap AI'>
<GenerateRoadmap client:load />
<LoginPopup />
</AccountLayout>
</BaseLayout>

View File

@@ -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';

View File

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

View File

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

View File

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

View File

@@ -7,13 +7,13 @@ import { TopicDetail } from '../../../components/TopicDetail/TopicDetail';
import UpcomingForm from '../../../components/UpcomingForm.astro';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import { UserProgressModal } from '../../../components/UserProgress/UserProgressModal';
import {
type BestPracticeFileType,
type BestPracticeFrontmatter,
getAllBestPractices,
} from '../../../lib/best-pratice';
import { generateArticleSchema } from '../../../lib/jsonld-schema';
import { getOpenGraphImageUrl } from '../../../lib/open-graph';
import {
BestPracticeFileType,
BestPracticeFrontmatter,
getAllBestPractices,
} from '../../../lib/best-practice';
export async function getStaticPaths() {
const bestPractices = await getAllBestPractices();
@@ -98,7 +98,12 @@ const ogImageUrl = getOpenGraphImageUrl({
pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`}
/>
<TopicDetail client:idle canSubmitContribution={true} />
<TopicDetail
resourceTitle={bestPracticeData.title}
resourceType='best-practice'
client:idle
canSubmitContribution={true}
/>
<FrameRenderer
resourceType={'best-practice'}

View File

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

View File

@@ -2,7 +2,7 @@
import GridItem from '../../components/GridItem.astro';
import SimplePageHeader from '../../components/SimplePageHeader.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getAllBestPractices } from '../../lib/best-pratice';
import { getAllBestPractices } from '../../lib/best-practice';
const bestPractices = await getAllBestPractices();
---

View File

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

View File

@@ -4,7 +4,7 @@ import FeaturedGuides from '../components/FeaturedGuides.astro';
import FeaturedItems from '../components/FeaturedItems/FeaturedItems.astro';
import HeroSection from '../components/HeroSection/HeroSection.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { getAllBestPractices } from '../lib/best-pratice';
import { getAllBestPractices } from '../lib/best-practice';
import { getAllGuides } from '../lib/guide';
import { getRoadmapsByTag } from '../lib/roadmap';
import { getAllVideos } from '../lib/video';

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