Compare commits
211 Commits
roadmap/de
...
fix/ai-roa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36e85453d0 | ||
|
|
a6311e587d | ||
|
|
943def6d7c | ||
|
|
612e750370 | ||
|
|
bf078c8b44 | ||
|
|
4408fd0218 | ||
|
|
00fa41773c | ||
|
|
058f5c3203 | ||
|
|
8dd5b5fecf | ||
|
|
46bfacff92 | ||
|
|
1779992250 | ||
|
|
92032e7f2f | ||
|
|
12da4018a6 | ||
|
|
560e9b0229 | ||
|
|
ecb803e78d | ||
|
|
a6189f45ef | ||
|
|
15c464a8e5 | ||
|
|
3daab9fbe6 | ||
|
|
d1272de92a | ||
|
|
f901053ee0 | ||
|
|
c01fed4f18 | ||
|
|
728bbea170 | ||
|
|
d59c99f168 | ||
|
|
8488e00254 | ||
|
|
f561a52a46 | ||
|
|
56b327177b | ||
|
|
575e05d493 | ||
|
|
e66822e1b9 | ||
|
|
09cb1ea827 | ||
|
|
d5fdc62343 | ||
|
|
44d3724880 | ||
|
|
9d4aae10b5 | ||
|
|
070d04334b | ||
|
|
7040b6637c | ||
|
|
cfa8d2a986 | ||
|
|
d2f372fd6f | ||
|
|
f6cd6419be | ||
|
|
dcef07d7c6 | ||
|
|
bd3fd8bfe2 | ||
|
|
44b62c2b2d | ||
|
|
d958a29862 | ||
|
|
37ffc2cc62 | ||
|
|
dd3a46e972 | ||
|
|
9f8fcb8265 | ||
|
|
6c99127fc4 | ||
|
|
9157e18ec7 | ||
|
|
84093e3525 | ||
|
|
04d0f7c0b1 | ||
|
|
f1f56408d5 | ||
|
|
d847eb0685 | ||
|
|
d697707384 | ||
|
|
fdee813a0b | ||
|
|
707f0097dc | ||
|
|
27e98b0eba | ||
|
|
8d677f3a22 | ||
|
|
a9734c7eeb | ||
|
|
7ba48523da | ||
|
|
c1ebe9ae47 | ||
|
|
415a9c0fd0 | ||
|
|
d3b8cbceaa | ||
|
|
c390f4428e | ||
|
|
2e890b1b25 | ||
|
|
7b9be9377b | ||
|
|
4863f08a4c | ||
|
|
81a14e90eb | ||
|
|
51dd58f7ed | ||
|
|
28a0fca90d | ||
|
|
be8495a60a | ||
|
|
cf5ac18aa1 | ||
|
|
0c7bc0e330 | ||
|
|
94ba9e7451 | ||
|
|
0de6ed6028 | ||
|
|
ef0c9f3db2 | ||
|
|
468e92bac1 | ||
|
|
733e9cb5af | ||
|
|
714ca8c49f | ||
|
|
63c3850f0e | ||
|
|
e2f7abe69a | ||
|
|
780402afd6 | ||
|
|
2b74f70ef9 | ||
|
|
1eab06f1f5 | ||
|
|
d2b7704370 | ||
|
|
a5d5f63677 | ||
|
|
dfc7821a44 | ||
|
|
1cee0b36dc | ||
|
|
0d1f916535 | ||
|
|
ce0f2a4ee4 | ||
|
|
bf89b013d1 | ||
|
|
5bcb3e282d | ||
|
|
747652c0f3 | ||
|
|
ed0e376d46 | ||
|
|
ef3e4fc3f3 | ||
|
|
932896c3af | ||
|
|
1539c6ccaf | ||
|
|
84aa35cdec | ||
|
|
b6a852b29b | ||
|
|
2d2f670153 | ||
|
|
5cf7aa340f | ||
|
|
601d21ca9d | ||
|
|
a5527dd872 | ||
|
|
4d6d943b4e | ||
|
|
85214da400 | ||
|
|
46eb27a810 | ||
|
|
e47bd63cc9 | ||
|
|
d314f3d8c1 | ||
|
|
52fdd8f07d | ||
|
|
22f59c66f0 | ||
|
|
4a862241d3 | ||
|
|
b1fdc7ff49 | ||
|
|
445bdabde5 | ||
|
|
c46b4220a7 | ||
|
|
cdcdfc4973 | ||
|
|
d4b4b3c55c | ||
|
|
2c0ebe4209 | ||
|
|
c51438142c | ||
|
|
d5a47b79db | ||
|
|
ca2a75537e | ||
|
|
f62faf214c | ||
|
|
00b9630669 | ||
|
|
49ba524c15 | ||
|
|
d4436e8a8f | ||
|
|
e0b3209dc4 | ||
|
|
cf5dd19652 | ||
|
|
16680e2629 | ||
|
|
b9b12333cb | ||
|
|
8a9bb60211 | ||
|
|
2c6bef62b2 | ||
|
|
efb7e13f7d | ||
|
|
b34c7eff65 | ||
|
|
15c43fda5d | ||
|
|
b38f34a722 | ||
|
|
f1780fabda | ||
|
|
5362a64c29 | ||
|
|
720809f139 | ||
|
|
5b03601aa2 | ||
|
|
90df308751 | ||
|
|
3c0545e54f | ||
|
|
4eb145dff4 | ||
|
|
966d5fedb5 | ||
|
|
243778cf11 | ||
|
|
9c9c59911b | ||
|
|
7a93301b5b | ||
|
|
aa056c1da8 | ||
|
|
13d1879977 | ||
|
|
aca3163ba9 | ||
|
|
5e80d9d4d8 | ||
|
|
0fc28c482a | ||
|
|
837d2ac782 | ||
|
|
68c62bacc2 | ||
|
|
720438e619 | ||
|
|
3afab1aa70 | ||
|
|
f40585f992 | ||
|
|
9232d03e24 | ||
|
|
01cb4b5131 | ||
|
|
50f02b504a | ||
|
|
2d12bffe46 | ||
|
|
3b1762cd91 | ||
|
|
d9be6e3c8b | ||
|
|
b65328ebc9 | ||
|
|
5da5924b6c | ||
|
|
b35a169315 | ||
|
|
9d05c64f50 | ||
|
|
e94296cdd4 | ||
|
|
7a4796508d | ||
|
|
e0f5d6f436 | ||
|
|
d103bc629c | ||
|
|
cb9943191e | ||
|
|
eaa567dfe0 | ||
|
|
277713e16b | ||
|
|
5ed49b965c | ||
|
|
a27aaf6e2d | ||
|
|
be02cc59ea | ||
|
|
068847af08 | ||
|
|
c6c91ef8fe | ||
|
|
8fb3e7983b | ||
|
|
80ec1a1c4b | ||
|
|
76d1ca1333 | ||
|
|
40357f7956 | ||
|
|
581f4a76a4 | ||
|
|
ef1a3031c4 | ||
|
|
3774f3c5ec | ||
|
|
b11da48f41 | ||
|
|
5edda5654c | ||
|
|
505077a545 | ||
|
|
9f4967929f | ||
|
|
27cb89494f | ||
|
|
ec556915e4 | ||
|
|
c61e44119d | ||
|
|
6f46d723bc | ||
|
|
ee6e3e4029 | ||
|
|
6e9fe97e5c | ||
|
|
13af03c930 | ||
|
|
78692ff13f | ||
|
|
54d7388b09 | ||
|
|
b609c43055 | ||
|
|
d83fe1279b | ||
|
|
fb3cb85c14 | ||
|
|
82dbca95fb | ||
|
|
7e702ee385 | ||
|
|
08fbb730ab | ||
|
|
cd80338fa6 | ||
|
|
fa33d0c339 | ||
|
|
8ec9a6e675 | ||
|
|
16853df928 | ||
|
|
c15d139d54 | ||
|
|
4e5cc5bd35 | ||
|
|
a36bca2f42 | ||
|
|
10b688049d | ||
|
|
0db92f6418 | ||
|
|
dccaa66ed4 | ||
|
|
3deee4dfc3 |
@@ -1,4 +1,4 @@
|
||||
name: Deployment to GH Pages
|
||||
name: App Deployment
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
2
.gitignore
vendored
@@ -30,4 +30,4 @@ tests-examples
|
||||
*.csv
|
||||
|
||||
/editor/*
|
||||
!/editor/readonly-editor.tsx
|
||||
!/editor/readonly-editor.tsx
|
||||
|
||||
11028
package-lock.json
generated
64
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "roadmap.sh",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev --port 3000",
|
||||
@@ -22,48 +22,52 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^3.0.3",
|
||||
"@astrojs/sitemap": "^3.0.2",
|
||||
"@astrojs/tailwind": "^5.0.2",
|
||||
"@fingerprintjs/fingerprintjs": "^4.1.0",
|
||||
"@astrojs/react": "^3.0.10",
|
||||
"@astrojs/sitemap": "^3.0.5",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@fingerprintjs/fingerprintjs": "^4.2.2",
|
||||
"@nanostores/react": "^0.7.1",
|
||||
"@types/react": "^18.2.31",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"astro": "^3.3.3",
|
||||
"astro-compress": "^2.1.5",
|
||||
"clsx": "^2.0.0",
|
||||
"dracula-prism": "^2.1.13",
|
||||
"jose": "^4.15.4",
|
||||
"@types/react": "^18.2.56",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"astro": "^4.4.0",
|
||||
"astro-compress": "^2.2.10",
|
||||
"clsx": "^2.1.0",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"dracula-prism": "^2.1.16",
|
||||
"jose": "^5.2.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.288.0",
|
||||
"nanoid": "^5.0.2",
|
||||
"nanostores": "^0.9.4",
|
||||
"node-html-parser": "^6.1.10",
|
||||
"npm-check-updates": "^16.14.6",
|
||||
"lucide-react": "^0.334.0",
|
||||
"nanoid": "^5.0.5",
|
||||
"nanostores": "^0.9.5",
|
||||
"node-html-parser": "^6.1.12",
|
||||
"npm-check-updates": "^16.14.15",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"reactflow": "^11.9.4",
|
||||
"reactflow": "^11.10.4",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"slugify": "^1.6.6",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"zustand": "^4.4.4"
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"unified": "^11.0.4",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.39.0",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/js-cookie": "^3.0.5",
|
||||
"@types/prismjs": "^1.26.2",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^6.0.0",
|
||||
"gh-pages": "^6.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"markdown-it": "^13.0.2",
|
||||
"openai": "^4.13.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-astro": "^0.12.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.6"
|
||||
"markdown-it": "^14.0.0",
|
||||
"openai": "^4.28.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-astro": "^0.13.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11"
|
||||
}
|
||||
}
|
||||
|
||||
3927
pnpm-lock.yaml
generated
BIN
public/authors/fernando.jpeg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/authors/peter-thaleikis.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
public/guides/backend-languages/back-vs-front.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
public/guides/backend-languages/backend-roadmap-part.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
public/guides/backend-languages/javascript-interest.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
public/guides/backend-languages/pypl-go-index.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
3
public/images/hackernews.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32">
|
||||
<path fill="#94a3b8" d="M5 5v22h22V5zm2 2h18v18H7zm4.5 4l3.5 6v5h2v-5l3.5-6h-2L16 15.281L13.5 11z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 203 B |
BIN
public/images/icons8-wand.gif
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/images/partners/spring-tile.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
public/images/partners/zilliz.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
1
public/images/reddit.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 1024 1024"><path fill="#94a3b8" d="M288 568a56 56 0 1 0 112 0a56 56 0 1 0-112 0zm338.7 119.7c-23.1 18.2-68.9 37.8-114.7 37.8s-91.6-19.6-114.7-37.8c-14.4-11.3-35.3-8.9-46.7 5.5s-8.9 35.3 5.5 46.7C396.3 771.6 457.5 792 512 792s115.7-20.4 155.9-52.1a33.25 33.25 0 1 0-41.2-52.2zM960 456c0-61.9-50.1-112-112-112c-42.1 0-78.7 23.2-97.9 57.6c-57.6-31.5-127.7-51.8-204.1-56.5L612.9 195l127.9 36.9c11.5 32.6 42.6 56.1 79.2 56.1c46.4 0 84-37.6 84-84s-37.6-84-84-84c-32 0-59.8 17.9-74 44.2L603.5 123a33.2 33.2 0 0 0-39.6 18.4l-90.8 203.9c-74.5 5.2-142.9 25.4-199.2 56.2A111.94 111.94 0 0 0 176 344c-61.9 0-112 50.1-112 112c0 45.8 27.5 85.1 66.8 102.5c-7.1 21-10.8 43-10.8 65.5c0 154.6 175.5 280 392 280s392-125.4 392-280c0-22.6-3.8-44.5-10.8-65.5C932.5 541.1 960 501.8 960 456zM820 172.5a31.5 31.5 0 1 1 0 63a31.5 31.5 0 0 1 0-63zM120 456c0-30.9 25.1-56 56-56a56 56 0 0 1 50.6 32.1c-29.3 22.2-53.5 47.8-71.5 75.9a56.23 56.23 0 0 1-35.1-52zm392 381.5c-179.8 0-325.5-95.6-325.5-213.5S332.2 410.5 512 410.5S837.5 506.1 837.5 624S691.8 837.5 512 837.5zM868.8 508c-17.9-28.1-42.2-53.7-71.5-75.9c9-18.9 28.3-32.1 50.6-32.1c30.9 0 56 25.1 56 56c.1 23.5-14.5 43.7-35.1 52zM624 568a56 56 0 1 0 112 0a56 56 0 1 0-112 0z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
0
public/manifest/apple-touch-icon.png
Executable file → Normal file
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
0
public/manifest/favicon.ico
Executable file → Normal file
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
0
public/manifest/icon152.png
Executable file → Normal file
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
0
public/manifest/icon16.png
Executable file → Normal file
|
Before Width: | Height: | Size: 123 B After Width: | Height: | Size: 123 B |
0
public/manifest/icon196.png
Executable file → Normal file
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
0
public/manifest/icon32.png
Executable file → Normal file
|
Before Width: | Height: | Size: 267 B After Width: | Height: | Size: 267 B |
BIN
public/pdfs/roadmaps/aws.pdf
Normal file
BIN
public/pdfs/roadmaps/datastructures-and-algorithms.pdf
Normal file
BIN
public/pdfs/roadmaps/mlops.pdf
Normal file
BIN
public/pdfs/roadmaps/rust.pdf
Normal file
BIN
public/pdfs/roadmaps/server-side-game-developer.pdf
Normal file
BIN
public/pdfs/roadmaps/technical-writer.pdf
Normal file
BIN
public/roadmaps/aws.png
Normal file
|
After Width: | Height: | Size: 636 KiB |
BIN
public/roadmaps/mlops.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
public/roadmaps/rust.png
Normal file
|
After Width: | Height: | Size: 599 KiB |
BIN
public/roadmaps/technical-writer.png
Normal file
|
After Width: | Height: | Size: 522 KiB |
@@ -31,15 +31,17 @@ Roadmaps are now interactive, you can click the nodes to read more about the top
|
||||
Here is the list of available roadmaps with more being actively worked upon.
|
||||
|
||||
- [Frontend Roadmap](https://roadmap.sh/frontend) / [Frontend Beginner Roadmap](https://roadmap.sh/frontend?r=frontend-beginner)
|
||||
- [Backend Roadmap](https://roadmap.sh/backend)
|
||||
- [Backend Roadmap](https://roadmap.sh/backend) / [Backend Beginner Roadmap](https://roadmap.sh/backend?r=backend-beginner)
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
|
||||
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
|
||||
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
|
||||
- [Data Structures and Algorithms Roadmap](https://roadmap.sh/datastructures-and-algorithms)
|
||||
- [AI and Data Scientist Roadmap](https://roadmap.sh/ai-data-scientist)
|
||||
- [MLOps Roadmap](https://roadmap.sh/mlops)
|
||||
- [QA Roadmap](https://roadmap.sh/qa)
|
||||
- [Python Roadmap](https://roadmap.sh/python)
|
||||
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
|
||||
- [Game Developer Roadmap](https://roadmap.sh/game-developer)
|
||||
- [Game Developer Roadmap](https://roadmap.sh/game-developer) / [Server Side Game Developer](https://roadmap.sh/server-side-game-developer)
|
||||
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
|
||||
- [JavaScript Roadmap](https://roadmap.sh/javascript)
|
||||
- [TypeScript Roadmap](https://roadmap.sh/typescript)
|
||||
@@ -53,6 +55,7 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [Android Roadmap](https://roadmap.sh/android)
|
||||
- [Flutter Roadmap](https://roadmap.sh/flutter)
|
||||
- [Go Roadmap](https://roadmap.sh/golang)
|
||||
- [Rust Roadmap](https://roadmap.sh/rust)
|
||||
- [Java Roadmap](https://roadmap.sh/java)
|
||||
- [Spring Boot Roadmap](https://roadmap.sh/spring-boot)
|
||||
- [Design System Roadmap](https://roadmap.sh/design-system)
|
||||
@@ -67,6 +70,7 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [UX Design Roadmap](https://roadmap.sh/ux-design)
|
||||
- [Docker Roadmap](https://roadmap.sh/docker)
|
||||
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
|
||||
- [Technical Writer Roadmap](https://roadmap.sh/technical-writer)
|
||||
|
||||
There are also interactive best practices:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-#!/usr/bin/env bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -59,9 +59,9 @@ function writeTopicContent(currTopicUrl) {
|
||||
|
||||
const roadmapTitle = roadmapId.replace(/-/g, ' ');
|
||||
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief paragraph for that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output.`;
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief paragraph for that. Your output should be strictly markdown. Do not include anything other than the description in your output. I already know the benefits of each so do not add benefits in the output.`;
|
||||
if (!childTopic) {
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief paragraph for that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output.`;
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief paragraph for that. Your output should be strictly markdown. Do not include anything other than the description in your output. I already know the benefits of each so do not add benefits in the output.`;
|
||||
}
|
||||
|
||||
console.log(`Generating '${childTopic || parentTopic}'...`);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import { RoadmapIcon } from "../ReactIcons/RoadmapIcon";
|
||||
|
||||
export function EmptyActivity() {
|
||||
return (
|
||||
<div className="rounded-md">
|
||||
<div className="flex flex-col items-center p-7 text-center">
|
||||
<img
|
||||
alt="no roadmaps"
|
||||
src={RoadmapIcon.src}
|
||||
className="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10"
|
||||
/>
|
||||
<RoadmapIcon className="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10" />
|
||||
|
||||
<h2 className="text-lg sm:text-xl font-bold">No Progress</h2>
|
||||
<p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base">
|
||||
Progress will appear here as you start tracking your{' '}
|
||||
|
||||
15
src/components/AppChecklist.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PartyPopper } from 'lucide-react';
|
||||
|
||||
export function AppChecklist() {
|
||||
return (
|
||||
<div className="fixed bottom-6 right-3">
|
||||
<a
|
||||
href="/get-started"
|
||||
className="flex items-center gap-2 rounded-full border border-slate-900 bg-white py-2 pl-3 pr-4 text-sm font-medium hover:bg-zinc-200"
|
||||
>
|
||||
<PartyPopper className="relative -top-[2px] h-[20px] w-[20px] text-purple-600" />
|
||||
Welcome! Start here
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,15 +11,16 @@ async function getSVG(name: string) {
|
||||
const filepath = `/src/icons/${name}.svg`;
|
||||
|
||||
const files = import.meta.glob<string>('/src/icons/**/*.svg', {
|
||||
query: '?raw',
|
||||
eager: true,
|
||||
as: 'raw',
|
||||
});
|
||||
|
||||
|
||||
if (!(filepath in files)) {
|
||||
throw new Error(`${filepath} not found`);
|
||||
}
|
||||
|
||||
const root = parse(files[filepath]);
|
||||
const root = parse(files[filepath].default as string);
|
||||
|
||||
const svg = root.querySelector('svg');
|
||||
|
||||
@@ -35,4 +36,4 @@ const { attributes: baseAttributes, innerHTML } = await getSVG(icon);
|
||||
const svgAttributes = { ...baseAttributes, ...attributes };
|
||||
---
|
||||
|
||||
<svg {...svgAttributes} set:html={innerHTML}></svg>
|
||||
<svg {...svgAttributes} set:html={innerHTML} />
|
||||
|
||||
41
src/components/AuthenticationFlow/AuthenticationForm.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { GitHubButton } from './GitHubButton';
|
||||
import { GoogleButton } from './GoogleButton';
|
||||
import { LinkedInButton } from './LinkedInButton';
|
||||
import { EmailLoginForm } from './EmailLoginForm';
|
||||
import { EmailSignupForm } from './EmailSignupForm';
|
||||
|
||||
type AuthenticationFormProps = {
|
||||
type?: 'login' | 'signup';
|
||||
};
|
||||
|
||||
export function AuthenticationForm(props: AuthenticationFormProps) {
|
||||
const { type = 'login' } = props;
|
||||
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<GitHubButton isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
|
||||
<GoogleButton isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
|
||||
<LinkedInButton isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-2 py-6 text-sm text-slate-600">
|
||||
<div className="h-px w-full bg-slate-200" />
|
||||
OR
|
||||
<div className="h-px w-full bg-slate-200" />
|
||||
</div>
|
||||
|
||||
{type === 'login' ? (
|
||||
<EmailLoginForm isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
|
||||
) : (
|
||||
<EmailSignupForm
|
||||
isDisabled={isDisabled}
|
||||
setIsDisabled={setIsDisabled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,16 @@ import Cookies from 'js-cookie';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
|
||||
|
||||
type EmailLoginFormProps = {
|
||||
isDisabled?: boolean;
|
||||
setIsDisabled?: (isDisabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function EmailLoginForm(props: EmailLoginFormProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
export function EmailLoginForm() {
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -14,6 +21,7 @@ export function EmailLoginForm() {
|
||||
const handleFormSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ token: string }>(
|
||||
@@ -26,11 +34,7 @@ export function EmailLoginForm() {
|
||||
|
||||
// Log the user in and reload the page
|
||||
if (response?.token) {
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
setAuthToken(response.token);
|
||||
window.location.reload();
|
||||
|
||||
return;
|
||||
@@ -45,6 +49,7 @@ export function EmailLoginForm() {
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
setError(error?.message || 'Something went wrong. Please try again later.');
|
||||
};
|
||||
|
||||
@@ -92,7 +97,7 @@ export function EmailLoginForm() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isDisabled}
|
||||
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'}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function EmailSignupForm() {
|
||||
type EmailSignupFormProps = {
|
||||
isDisabled?: boolean;
|
||||
setIsDisabled?: (isDisabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function EmailSignupForm(props: EmailSignupFormProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
@@ -13,6 +20,7 @@ export function EmailSignupForm() {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ status: 'ok' }>(
|
||||
@@ -21,20 +29,21 @@ export function EmailSignupForm() {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error || response?.status !== 'ok') {
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
setError(
|
||||
error?.message || 'Something went wrong. Please try again later.'
|
||||
error?.message || 'Something went wrong. Please try again later.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/verification-pending?email=${encodeURIComponent(
|
||||
email
|
||||
email,
|
||||
)}`;
|
||||
};
|
||||
|
||||
@@ -90,7 +99,7 @@ export function EmailSignupForm() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isDisabled}
|
||||
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 to Verify Email'}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import GitHubIcon from '../../icons/github.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
|
||||
type GitHubButtonProps = {};
|
||||
type GitHubButtonProps = {
|
||||
isDisabled?: boolean;
|
||||
setIsDisabled?: (isDisabled: boolean) => void;
|
||||
};
|
||||
|
||||
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
|
||||
const GITHUB_LAST_PAGE = 'githubLastPage';
|
||||
|
||||
export function GitHubButton(props: GitHubButtonProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : GitHubIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -26,16 +30,18 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-callback${
|
||||
window.location.search
|
||||
}`
|
||||
}`,
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
const errMessage = error?.message || 'Something went wrong.';
|
||||
setError(errMessage);
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -64,40 +70,39 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
|
||||
localStorage.removeItem(GITHUB_REDIRECT_AT);
|
||||
localStorage.removeItem(GITHUB_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
setAuthToken(response.token);
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
|
||||
const { response, error } = await httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`,
|
||||
);
|
||||
|
||||
if (error || !response?.loginUrl) {
|
||||
setError(
|
||||
error?.message || 'Something went wrong. Please try again later.'
|
||||
error?.message || 'Something went wrong. Please try again later.',
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath = ['/respond-invite', '/befriend'].includes(
|
||||
window.location.pathname
|
||||
const pagePath = ['/respond-invite', '/befriend', '/r', '/ai'].includes(
|
||||
window.location.pathname,
|
||||
)
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
@@ -113,14 +118,14 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
<>
|
||||
<button
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isDisabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon.src}
|
||||
alt="GitHub"
|
||||
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
|
||||
) : (
|
||||
<GitHubIcon className={'h-[18px] w-[18px]'} />
|
||||
)}
|
||||
Continue with GitHub
|
||||
</button>
|
||||
{error && (
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import GoogleIcon from '../../icons/google.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx';
|
||||
|
||||
type GoogleButtonProps = {};
|
||||
type GoogleButtonProps = {
|
||||
isDisabled?: boolean;
|
||||
setIsDisabled?: (isDisabled: boolean) => void;
|
||||
};
|
||||
|
||||
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
|
||||
const GOOGLE_LAST_PAGE = 'googleLastPage';
|
||||
|
||||
export function GoogleButton(props: GoogleButtonProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : GoogleIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -26,15 +30,17 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-callback${
|
||||
window.location.search
|
||||
}`
|
||||
}`,
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -63,28 +69,27 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
|
||||
localStorage.removeItem(GOOGLE_REDIRECT_AT);
|
||||
localStorage.removeItem(GOOGLE_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
setAuthToken(response.token);
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-login`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-login`,
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -92,8 +97,8 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath = ['/respond-invite', '/befriend'].includes(
|
||||
window.location.pathname
|
||||
const pagePath = ['/respond-invite', '/befriend', '/r', '/ai'].includes(
|
||||
window.location.pathname,
|
||||
)
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
@@ -107,6 +112,7 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -114,14 +120,14 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
<>
|
||||
<button
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isDisabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon.src}
|
||||
alt="Google"
|
||||
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
|
||||
) : (
|
||||
<GoogleIcon className={'h-[18px] w-[18px]'} />
|
||||
)}
|
||||
Continue with Google
|
||||
</button>
|
||||
{error && (
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import LinkedIn from '../../icons/linkedin.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { LinkedInIcon } from '../ReactIcons/LinkedInIcon.tsx';
|
||||
|
||||
type LinkedInButtonProps = {};
|
||||
type LinkedInButtonProps = {
|
||||
isDisabled?: boolean;
|
||||
setIsDisabled?: (isDisabled: boolean) => void;
|
||||
};
|
||||
|
||||
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
|
||||
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
|
||||
|
||||
export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : LinkedIn;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -26,15 +30,17 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-callback${
|
||||
window.location.search
|
||||
}`
|
||||
}`,
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -63,28 +69,27 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
|
||||
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
|
||||
localStorage.removeItem(LINKEDIN_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
setAuthToken(response.token);
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`,
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -92,8 +97,8 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath = ['/respond-invite', '/befriend'].includes(
|
||||
window.location.pathname
|
||||
const pagePath = ['/respond-invite', '/befriend', '/r', '/ai'].includes(
|
||||
window.location.pathname,
|
||||
)
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
@@ -107,6 +112,7 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -114,14 +120,14 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
<>
|
||||
<button
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isDisabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon.src}
|
||||
alt="Google"
|
||||
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
|
||||
) : (
|
||||
<LinkedInIcon className={'h-[18px] w-[18px]'} />
|
||||
)}
|
||||
Continue with LinkedIn
|
||||
</button>
|
||||
{error && (
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
---
|
||||
import Popup from '../Popup/Popup.astro';
|
||||
import { EmailLoginForm } from './EmailLoginForm';
|
||||
import Divider from './Divider.astro';
|
||||
import { GitHubButton } from './GitHubButton';
|
||||
import { GoogleButton } from './GoogleButton';
|
||||
import { LinkedInButton } from './LinkedInButton';
|
||||
import { AuthenticationForm } from './AuthenticationForm';
|
||||
---
|
||||
|
||||
<Popup id='login-popup' title='' subtitle=''>
|
||||
<div class='text-center'>
|
||||
<div class='mb-7 text-center'>
|
||||
<p class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
|
||||
Login to your account
|
||||
</p>
|
||||
@@ -16,19 +12,9 @@ import { LinkedInButton } from './LinkedInButton';
|
||||
You must be logged in to perform this action.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class='mt-7 flex flex-col gap-2'>
|
||||
<GitHubButton client:load />
|
||||
<GoogleButton client:load />
|
||||
<LinkedInButton client:load />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<EmailLoginForm client:load />
|
||||
|
||||
<AuthenticationForm client:load />
|
||||
<div class='mt-6 text-center text-sm text-slate-600'>
|
||||
Don't have an account?{' '}
|
||||
<a href='/signup' class='font-medium text-[#4285f4]'>Sign up</a>
|
||||
<a href='/signup' class='font-medium text-[#4285f4]'> Sign up</a>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
|
||||
|
||||
export function ResetPasswordForm() {
|
||||
const [code, setCode] = useState('');
|
||||
@@ -37,7 +37,7 @@ export function ResetPasswordForm() {
|
||||
newPassword: password,
|
||||
confirmPassword: passwordConfirm,
|
||||
code,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error?.message) {
|
||||
@@ -53,11 +53,7 @@ export function ResetPasswordForm() {
|
||||
}
|
||||
|
||||
const token = response.token;
|
||||
Cookies.set(TOKEN_COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
setAuthToken(response.token);
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import ErrorIcon from '../../icons/error.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { ErrorIcon2 } from '../ReactIcons/ErrorIcon2';
|
||||
|
||||
export function TriggerVerifyAccount() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -16,7 +16,7 @@ export function TriggerVerifyAccount() {
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-verify-account`,
|
||||
{
|
||||
code,
|
||||
}
|
||||
},
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
@@ -26,11 +26,7 @@ export function TriggerVerifyAccount() {
|
||||
return;
|
||||
}
|
||||
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
setAuthToken(response.token);
|
||||
window.location.href = '/';
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -55,20 +51,8 @@ export function TriggerVerifyAccount() {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
{isLoading && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={SpinnerIcon.src}
|
||||
className={'mx-auto h-16 w-16 animate-spin'}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={ErrorIcon.src}
|
||||
className={'mx-auto h-16 w-16'}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Spinner className="mx-auto h-16 w-16" />}
|
||||
{error && <ErrorIcon2 className="mx-auto h-16 w-16" />}
|
||||
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl">
|
||||
Verifying your account
|
||||
</h2>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import VerifyLetterIcon from '../../icons/verify-letter.svg';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { VerifyLetterIcon } from '../ReactIcons/VerifyLetterIcon';
|
||||
|
||||
export function VerificationEmailMessage() {
|
||||
const [email, setEmail] = useState('..');
|
||||
@@ -37,11 +37,7 @@ export function VerificationEmailMessage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<img
|
||||
alt="Verify Email"
|
||||
src={VerifyLetterIcon.src}
|
||||
className="mx-auto mb-4 h-20 w-40 sm:h-40"
|
||||
/>
|
||||
<VerifyLetterIcon className="mx-auto mb-4 h-20 w-40 sm:h-40" />
|
||||
<h2 className="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
|
||||
Verify your email address
|
||||
</h2>
|
||||
|
||||
@@ -1,35 +1,47 @@
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Fragment,
|
||||
type ReactElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import BestPracticesIcon from '../../icons/best-practices.svg';
|
||||
import ClipboardIcon from '../../icons/clipboard.svg';
|
||||
import GuideIcon from '../../icons/guide.svg';
|
||||
import HomeIcon from '../../icons/home.svg';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import UserIcon from '../../icons/user.svg';
|
||||
import GroupIcon from '../../icons/group.svg';
|
||||
import VideoIcon from '../../icons/video.svg';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { BestPracticesIcon } from '../ReactIcons/BestPracticesIcon.tsx';
|
||||
import { UserIcon } from '../ReactIcons/UserIcon.tsx';
|
||||
import { GroupIcon } from '../ReactIcons/GroupIcon.tsx';
|
||||
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon.tsx';
|
||||
import { ClipboardIcon } from '../ReactIcons/ClipboardIcon.tsx';
|
||||
import { GuideIcon } from '../ReactIcons/GuideIcon.tsx';
|
||||
import { HomeIcon } from '../ReactIcons/HomeIcon.tsx';
|
||||
import { VideoIcon } from '../ReactIcons/VideoIcon.tsx';
|
||||
|
||||
export type PageType = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
group: string;
|
||||
icon?: string;
|
||||
icon?: ReactElement;
|
||||
isProtected?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon.src },
|
||||
{
|
||||
id: 'home',
|
||||
url: '/',
|
||||
title: 'Home',
|
||||
group: 'Pages',
|
||||
icon: <HomeIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
url: '/account',
|
||||
title: 'Account',
|
||||
group: 'Pages',
|
||||
icon: UserIcon.src,
|
||||
icon: <UserIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
@@ -37,7 +49,7 @@ const defaultPages: PageType[] = [
|
||||
url: '/team',
|
||||
title: 'Teams',
|
||||
group: 'Pages',
|
||||
icon: GroupIcon.src,
|
||||
icon: <GroupIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
@@ -45,7 +57,7 @@ const defaultPages: PageType[] = [
|
||||
url: '/account/friends',
|
||||
title: 'Friends',
|
||||
group: 'Pages',
|
||||
icon: GroupIcon.src,
|
||||
icon: <GroupIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
@@ -53,14 +65,14 @@ const defaultPages: PageType[] = [
|
||||
url: '/roadmaps',
|
||||
title: 'Roadmaps',
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon.src,
|
||||
icon: <RoadmapIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
{
|
||||
id: 'account-roadmaps',
|
||||
url: '/account/roadmaps',
|
||||
title: 'Custom Roadmaps',
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon.src,
|
||||
icon: <RoadmapIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
@@ -68,28 +80,28 @@ const defaultPages: PageType[] = [
|
||||
url: '/best-practices',
|
||||
title: 'Best Practices',
|
||||
group: 'Pages',
|
||||
icon: BestPracticesIcon.src,
|
||||
icon: <BestPracticesIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
{
|
||||
id: 'questions',
|
||||
url: '/questions',
|
||||
title: 'Questions',
|
||||
group: 'Pages',
|
||||
icon: ClipboardIcon.src,
|
||||
icon: <ClipboardIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
{
|
||||
id: 'guides',
|
||||
url: '/guides',
|
||||
title: 'Guides',
|
||||
group: 'Pages',
|
||||
icon: GuideIcon.src,
|
||||
icon: <GuideIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
{
|
||||
id: 'videos',
|
||||
url: '/videos',
|
||||
title: 'Videos',
|
||||
group: 'Pages',
|
||||
icon: VideoIcon.src,
|
||||
icon: <VideoIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -199,7 +211,7 @@ export function CommandMenu() {
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
const canGoPrev = activeCounter > 0;
|
||||
setActiveCounter(
|
||||
canGoPrev ? activeCounter - 1 : searchResults.length - 1
|
||||
canGoPrev ? activeCounter - 1 : searchResults.length - 1,
|
||||
);
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
@@ -242,13 +254,7 @@ export function CommandMenu() {
|
||||
{!page.icon && (
|
||||
<span className="mr-2 text-gray-400">{page.group}</span>
|
||||
)}
|
||||
{page.icon && (
|
||||
<img
|
||||
alt={page.title}
|
||||
src={page.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
{page.icon && page.icon}
|
||||
{page.title}
|
||||
</a>
|
||||
</Fragment>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
|
||||
|
||||
type NotDropdownProps = {
|
||||
onClick: () => void;
|
||||
@@ -37,11 +37,7 @@ export function NotDropdown(props: NotDropdownProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
alt={singularName}
|
||||
src={ChevronDownIcon.src}
|
||||
className={'relative top-[1px] h-[17px] w-[17px] opacity-40'}
|
||||
/>
|
||||
<ChevronDownIcon className="relative top-[1px] h-[17px] w-[17px] opacity-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
import { SelectRoadmapModalItem } from './SelectRoadmapModalItem';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
export type SelectRoadmapModalProps = {
|
||||
teamId: string;
|
||||
@@ -60,11 +60,11 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
setSearchResults(searchResults);
|
||||
}, [searchText, allRoadmaps]);
|
||||
|
||||
const roleBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('role-roadmap')
|
||||
const roleBasedRoadmaps = searchResults.filter(
|
||||
(roadmap) => roadmap?.metadata?.tags?.includes('role-roadmap'),
|
||||
);
|
||||
const skillBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('skill-roadmap')
|
||||
const skillBasedRoadmaps = searchResults.filter(
|
||||
(roadmap) => roadmap?.metadata?.tags?.includes('skill-roadmap'),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -79,7 +79,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
className="popup-close absolute right-2.5 top-3 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-900"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img alt={'close'} src={CloseIcon.src} className="h-4 w-4" />
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close modal</span>
|
||||
</button>
|
||||
<input
|
||||
@@ -101,7 +101,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
{roleBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig?.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
(r) => r.resourceId === roadmap.id,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -127,7 +127,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{skillBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
(r) => r.resourceId === roadmap.id,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import BuildingIcon from '../../icons/building.svg';
|
||||
import UsersIcon from '../../icons/users.svg';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { httpPut } from '../../lib/http';
|
||||
import { useState } from 'react';
|
||||
import { NextButton } from './NextButton';
|
||||
import { BuildingIcon } from '../ReactIcons/BuildingIcon.tsx';
|
||||
import { UsersIcon } from '../ReactIcons/UsersIcon.tsx';
|
||||
|
||||
export const validTeamTypes = [
|
||||
{
|
||||
value: 'company',
|
||||
label: 'Company',
|
||||
icon: BuildingIcon.src,
|
||||
icon: BuildingIcon,
|
||||
description:
|
||||
'Track the skills and learning progress of the tech team at your company',
|
||||
},
|
||||
{
|
||||
value: 'study_group',
|
||||
label: 'Study Group',
|
||||
icon: UsersIcon.src,
|
||||
icon: UsersIcon,
|
||||
description:
|
||||
'Invite your friends or course-mates and track your learning progress together',
|
||||
},
|
||||
@@ -56,7 +56,7 @@ export function Step0(props: Step0Props) {
|
||||
teamSize: team.teamSize,
|
||||
linkedInUrl: team?.links?.linkedIn || undefined,
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -76,21 +76,20 @@ export function Step0(props: Step0Props) {
|
||||
{validTeamTypes.map((validTeamType) => (
|
||||
<button
|
||||
key={validTeamType.value}
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 pt-12 pb-10 ${
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 pb-10 pt-12 ${
|
||||
validTeamType.value == selectedTeamType
|
||||
? 'border-gray-400 bg-gray-100'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedTeamType(validTeamType.value)}
|
||||
>
|
||||
<img
|
||||
key={validTeamType.value}
|
||||
alt={validTeamType.label}
|
||||
src={validTeamType.icon}
|
||||
className={`mb-3 h-12 w-12 opacity-10 ${
|
||||
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
|
||||
}`}
|
||||
/>
|
||||
{
|
||||
<validTeamType.icon
|
||||
className={`mb-3 h-12 w-12 opacity-10 ${
|
||||
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
|
||||
}`}
|
||||
/>
|
||||
}
|
||||
<span className="mb-2 block text-2xl font-bold">
|
||||
{validTeamType.label}
|
||||
</span>
|
||||
|
||||
@@ -53,14 +53,20 @@ export type GetRoadmapResponse = RoadmapDocument & {
|
||||
|
||||
export function hideRoadmapLoader() {
|
||||
const loaderEl = document.querySelector(
|
||||
'[data-roadmap-loader]'
|
||||
'[data-roadmap-loader]',
|
||||
) as HTMLElement;
|
||||
if (loaderEl) {
|
||||
loaderEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomRoadmap() {
|
||||
type CustomRoadmapProps = {
|
||||
isEmbed?: boolean;
|
||||
};
|
||||
|
||||
export function CustomRoadmap(props: CustomRoadmapProps) {
|
||||
const { isEmbed = false } = props;
|
||||
|
||||
const { id, secret } = getUrlParams() as { id: string; secret: string };
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -71,14 +77,15 @@ export function CustomRoadmap() {
|
||||
setIsLoading(true);
|
||||
|
||||
const roadmapUrl = new URL(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
roadmapUrl.searchParams.set('secret', secret);
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<GetRoadmapResponse>(
|
||||
roadmapUrl.toString()
|
||||
roadmapUrl.toString(),
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -94,19 +101,10 @@ export function CustomRoadmap() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function trackVisit() {
|
||||
if (!isLoggedIn()) return;
|
||||
await httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
|
||||
resourceId: id,
|
||||
resourceType: 'roadmap',
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getRoadmap().finally(() => {
|
||||
hideRoadmapLoader();
|
||||
});
|
||||
trackVisit().then();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -119,9 +117,9 @@ export function CustomRoadmap() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoadmapHeader />
|
||||
<FlowRoadmapRenderer roadmap={roadmap!} />
|
||||
<TopicDetail canSubmitContribution={false} />
|
||||
{!isEmbed && <RoadmapHeader />}
|
||||
<FlowRoadmapRenderer isEmbed={isEmbed} roadmap={roadmap!} />
|
||||
<TopicDetail isEmbed={isEmbed} canSubmitContribution={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
55
src/components/CustomRoadmap/CustomRoadmapAlert.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { BadgeCheck, MessageCircleHeart, PencilRuler } from 'lucide-react';
|
||||
import { showLoginPopup } from '../../lib/popup.ts';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import { useState } from 'react';
|
||||
import { CreateRoadmapModal } from './CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
|
||||
export function CustomRoadmapAlert() {
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="relative mb-5 mt-0 rounded-md border border-yellow-500 bg-yellow-100 p-2 sm:-mt-6 sm:mb-7 sm:p-2.5">
|
||||
<h2 className="text-base font-semibold text-yellow-800 sm:text-lg">
|
||||
Community Roadmap
|
||||
</h2>
|
||||
<p className="mt-2 mb-2.5 sm:mb-1.5 sm:mt-1 text-sm text-yellow-800 sm:text-base">
|
||||
This is a custom roadmap made by a community member and is not verified by{' '}
|
||||
<span className="font-semibold">roadmap.sh</span>
|
||||
</p>
|
||||
<div className="flex items-start sm:items-center flex-col sm:flex-row gap-2">
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
|
||||
>
|
||||
<BadgeCheck className="h-4 w-4 stroke-[2.5]" />
|
||||
Visit Official Roadmaps
|
||||
</a>
|
||||
<span className="font-black text-yellow-700 hidden sm:block">·</span>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
} else {
|
||||
setIsCreatingRoadmap(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PencilRuler className="h-4 w-4 stroke-[2.5]" />
|
||||
Create Your Own Roadmap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MessageCircleHeart className="absolute bottom-2 right-2 hidden h-12 w-12 text-yellow-500 opacity-50 sm:block" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
src/components/CustomRoadmap/EmbedRoadmapModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
|
||||
import { Modal } from '../Modal';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import { currentRoadmap, isCurrentRoadmapPersonal } from '../../stores/roadmap';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
type ShareRoadmapModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function EmbedRoadmapModal(props: ShareRoadmapModalProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const $currentRoadmap = useStore(currentRoadmap);
|
||||
const $isCurrentRoadmapPersonal = useStore(isCurrentRoadmapPersonal);
|
||||
const roadmapId = $currentRoadmap?._id!;
|
||||
|
||||
const { copyText, isCopied } = useCopyText();
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
const baseUrl = isDev ? 'http://localhost:3000' : 'https://roadmap.sh';
|
||||
|
||||
const embedHtml = `<iframe src="${baseUrl}/r/embed?id=${roadmapId}" width="100%" height="500px" frameBorder="0"\n></iframe>`;
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} wrapperClassName={'max-w-[500px]'}>
|
||||
<div className="p-4 pb-0">
|
||||
<h1 className="text-xl font-semibold leading-5 text-gray-900">
|
||||
Embed Roadmap
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-3">
|
||||
<p className={'mb-2 text-sm text-gray-500'}>
|
||||
Copy the following HTML code and paste it into your website.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={embedHtml}
|
||||
readOnly={true}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(embedHtml);
|
||||
}}
|
||||
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 pb-4 pt-2">
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white outline-none',
|
||||
{
|
||||
'bg-green-500 hover:bg-green-600 focus:bg-green-600': isCopied,
|
||||
'bg-gray-900 hover:bg-gray-800 focus:bg-gray-800': !isCopied,
|
||||
},
|
||||
)}
|
||||
onClick={() => {
|
||||
copyText(embedHtml);
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check size={14} className="mr-2 stroke-[2.5]" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={14} className="mr-2 stroke-[2.5]" />
|
||||
Copy Link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
import { ReadonlyEditor } from '../../../editor/readonly-editor';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import {
|
||||
renderResourceProgress,
|
||||
updateResourceProgress,
|
||||
type ResourceProgressType,
|
||||
renderTopicProgress,
|
||||
refreshProgressCounters,
|
||||
renderResourceProgress,
|
||||
renderTopicProgress,
|
||||
type ResourceProgressType,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { Node } from 'reactflow';
|
||||
import { useCallback, type MouseEvent, useMemo, useState, useRef } from 'react';
|
||||
import { type MouseEvent, useCallback, useRef, useState } from 'react';
|
||||
import { EmptyRoadmap } from './EmptyRoadmap';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { totalRoadmapNodes } from '../../stores/roadmap.ts';
|
||||
|
||||
type FlowRoadmapRendererProps = {
|
||||
isEmbed?: boolean;
|
||||
roadmap: RoadmapDocument;
|
||||
};
|
||||
|
||||
export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
|
||||
const { roadmap } = props;
|
||||
const { roadmap, isEmbed = false } = props;
|
||||
const roadmapId = String(roadmap._id!);
|
||||
|
||||
const [hideRenderer, setHideRenderer] = useState(false);
|
||||
@@ -31,6 +33,10 @@ export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
|
||||
topicId: string,
|
||||
newStatus: ResourceProgressType,
|
||||
) {
|
||||
if (isEmbed) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('Updating progress');
|
||||
updateResourceProgress(
|
||||
{
|
||||
@@ -138,6 +144,12 @@ export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
|
||||
)}
|
||||
onRendered={() => {
|
||||
renderResourceProgress('roadmap', roadmapId).then(() => {
|
||||
totalRoadmapNodes.set(
|
||||
roadmap?.nodes?.filter((node) => {
|
||||
return ['topic', 'subtopic'].includes(node.type);
|
||||
}).length || 0,
|
||||
);
|
||||
|
||||
if (roadmap?.nodes?.length === 0) {
|
||||
setHideRenderer(true);
|
||||
editorWrapperRef?.current?.classList.add('hidden');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import MoreIcon from '../../icons/more-vertical.svg';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
|
||||
import { MoreVerticalIcon } from '../ReactIcons/MoreVerticalIcon.tsx';
|
||||
|
||||
type PersonalRoadmapActionDropdownProps = {
|
||||
onDelete?: () => void;
|
||||
@@ -9,7 +9,9 @@ type PersonalRoadmapActionDropdownProps = {
|
||||
onUpdateSharing?: () => void;
|
||||
};
|
||||
|
||||
export function PersonalRoadmapActionDropdown(props: PersonalRoadmapActionDropdownProps) {
|
||||
export function PersonalRoadmapActionDropdown(
|
||||
props: PersonalRoadmapActionDropdownProps,
|
||||
) {
|
||||
const { onDelete, onUpdateSharing, onCustomize } = props;
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -26,7 +28,7 @@ export function PersonalRoadmapActionDropdown(props: PersonalRoadmapActionDropdo
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex"
|
||||
>
|
||||
<img alt="menu" src={MoreIcon.src} className="h-4 w-4" />
|
||||
<MoreVerticalIcon className={'h-4 w-4'} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
type AllowedRoadmapVisibility,
|
||||
type RoadmapDocument,
|
||||
} from './CreateRoadmap/CreateRoadmapModal';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
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";
|
||||
|
||||
type PersonalRoadmapListType = {
|
||||
roadmaps: GetRoadmapListResponse['personalRoadmaps'];
|
||||
@@ -91,11 +91,8 @@ export function PersonalRoadmapList(props: PersonalRoadmapListType) {
|
||||
if (roadmapList.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 py-20">
|
||||
<img
|
||||
alt="roadmap"
|
||||
src={RoadmapIcon.src}
|
||||
className="mb-4 h-24 w-24 opacity-10"
|
||||
/>
|
||||
<RoadmapIcon className="mb-4 h-24 w-24 opacity-10" />
|
||||
|
||||
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
|
||||
<p className="text-base text-gray-500">
|
||||
Create a roadmap to get started
|
||||
|
||||
@@ -11,6 +11,8 @@ import { RoadmapActionButton } from './RoadmapActionButton';
|
||||
import { Lock, Shapes } from 'lucide-react';
|
||||
import { Modal } from '../Modal';
|
||||
import { ShareSuccess } from '../ShareOptions/ShareSuccess';
|
||||
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
|
||||
import { CustomRoadmapAlert } from './CustomRoadmapAlert.tsx';
|
||||
|
||||
type RoadmapHeaderProps = {};
|
||||
|
||||
@@ -44,11 +46,11 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
}
|
||||
},
|
||||
));
|
||||
} else {
|
||||
({ error, response } = await httpDelete<TeamResourceConfig>(
|
||||
`${baseApiUrl}/v1-delete-roadmap/${roadmapId}`
|
||||
`${baseApiUrl}/v1-delete-roadmap/${roadmapId}`,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -88,6 +90,8 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container relative py-5 sm:py-12">
|
||||
{!$canManageCurrentRoadmap && <CustomRoadmapAlert />}
|
||||
|
||||
{creator?.name && (
|
||||
<div className="-mb-1 flex items-center gap-1.5 text-sm text-gray-500">
|
||||
<img
|
||||
@@ -119,7 +123,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 sm:gap-0">
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<div className="flex justify-stretch gap-1 sm:gap-2">
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
@@ -128,14 +132,12 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
←<span className="hidden sm:inline"> All Roadmaps</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
data-guest-required
|
||||
data-popup="login-popup"
|
||||
className="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
|
||||
aria-label="Subscribe for Updates"
|
||||
>
|
||||
<span className="ml-2">Subscribe</span>
|
||||
</button>
|
||||
<ShareRoadmapButton
|
||||
roadmapId={roadmapId!}
|
||||
description={description!}
|
||||
pageUrl={`https://roadmap.sh/r?id=${roadmapId}`}
|
||||
allowEmbed={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{$canManageCurrentRoadmap && (
|
||||
@@ -162,9 +164,9 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
|
||||
$currentRoadmap?._id
|
||||
}`}
|
||||
href={`${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${$currentRoadmap?._id}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
|
||||
>
|
||||
@@ -183,7 +185,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
<RoadmapActionButton
|
||||
onDelete={() => {
|
||||
const confirmation = window.confirm(
|
||||
'Are you sure you want to delete this roadmap?'
|
||||
'Are you sure you want to delete this roadmap?',
|
||||
);
|
||||
|
||||
if (!confirmation) {
|
||||
|
||||
@@ -12,7 +12,10 @@ export function SkeletonRoadmapHeader() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 sm:gap-0">
|
||||
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-32" />
|
||||
<div className='flex gap-1 sm:gap-2'>
|
||||
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-32" />
|
||||
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[85px]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139.71px]" />
|
||||
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[100.34px]" />
|
||||
|
||||
149
src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { Eye, Loader2, RefreshCcw } from 'lucide-react';
|
||||
import { AIRoadmapAlert } from '../GenerateRoadmap/AIRoadmapAlert.tsx';
|
||||
|
||||
export interface AIRoadmapDocument {
|
||||
_id?: string;
|
||||
term: string;
|
||||
title: string;
|
||||
data: string;
|
||||
viewCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
type ExploreRoadmapsResponse = {
|
||||
data: AIRoadmapDocument[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
currPage: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export function ExploreAIRoadmap() {
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [roadmaps, setRoadmaps] = useState<AIRoadmapDocument[]>([]);
|
||||
const [currPage, setCurrPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
const loadAIRoadmaps = useCallback(
|
||||
async (currPage: number) => {
|
||||
const { response, error } = await httpGet<ExploreRoadmapsResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-ai-roadmaps`,
|
||||
{
|
||||
currPage,
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
const newRoadmaps = [...roadmaps, ...response.data];
|
||||
if (
|
||||
JSON.stringify(roadmaps) === JSON.stringify(response.data) ||
|
||||
JSON.stringify(roadmaps) === JSON.stringify(newRoadmaps)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRoadmaps(newRoadmaps);
|
||||
setCurrPage(response.currPage);
|
||||
setTotalPages(response.totalPages);
|
||||
},
|
||||
[currPage, roadmaps],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadAIRoadmaps(currPage).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hasMorePages = currPage < totalPages;
|
||||
|
||||
return (
|
||||
<section className="container mx-auto py-3 sm:py-6">
|
||||
<div className="mb-6">
|
||||
<AIRoadmapAlert isListing />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{new Array(21).fill(0).map((_, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="h-[75px] animate-pulse rounded-md border bg-gray-100"
|
||||
></li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div>
|
||||
{roadmaps?.length === 0 ? (
|
||||
<div className="text-center text-gray-800">No roadmaps found</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{roadmaps.map((roadmap) => {
|
||||
const roadmapLink = `/ai?id=${roadmap._id}`;
|
||||
return (
|
||||
<a
|
||||
key={roadmap._id}
|
||||
href={roadmapLink}
|
||||
className="flex flex-col rounded-md border transition-colors hover:bg-gray-100"
|
||||
target={'_blank'}
|
||||
>
|
||||
<h2 className="flex-grow px-2.5 py-2.5 text-base font-medium leading-tight">
|
||||
{roadmap.title}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<Eye size={15} className="inline-block" />
|
||||
{Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
}).format(roadmap.viewCount)}{' '}
|
||||
views
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
{getRelativeTimeString(String(roadmap?.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hasMorePages && (
|
||||
<div className="my-5 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsLoadingMore(true);
|
||||
loadAIRoadmaps(currPage + 1).finally(() => {
|
||||
setIsLoadingMore(false);
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-black px-3 py-1.5 text-sm font-medium text-white shadow-xl transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin stroke-[2.5]" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4 stroke-[2.5]" />
|
||||
)}
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -48,11 +48,10 @@ const {
|
||||
{
|
||||
isNew && (
|
||||
<span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-purple-300'>
|
||||
<span class='mr-1.5 flex h-2 w-2'>
|
||||
<span class='flex h-2 w-2'>
|
||||
<span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-purple-400 opacity-75' />
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-purple-500' />
|
||||
</span>
|
||||
New
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ import Icon from './AstroIcon.astro';
|
||||
class='my-1.5 mr-auto sm:ml-auto sm:mr-0'
|
||||
width='200'
|
||||
height='24.8'
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<p class='my-4 text-slate-300/60'>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import Loader from '../Loader.astro';
|
||||
import './FrameRenderer.css';
|
||||
import { ProgressNudge } from "./ProgressNudge";
|
||||
|
||||
export interface Props {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
@@ -27,4 +28,6 @@ const { resourceId, resourceType, dimensions = null } = Astro.props;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProgressNudge resourceId={resourceId} resourceType={resourceType} client:only="react" />
|
||||
|
||||
<script src='./renderer.ts'></script>
|
||||
|
||||
65
src/components/FrameRenderer/ProgressNudge.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { roadmapProgress, totalRoadmapNodes } from '../../stores/roadmap.ts';
|
||||
import { useStore } from '@nanostores/react';
|
||||
|
||||
type ProgressNudgeProps = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
export function ProgressNudge(props: ProgressNudgeProps) {
|
||||
const $totalRoadmapNodes = useStore(totalRoadmapNodes);
|
||||
const $roadmapProgress = useStore(roadmapProgress);
|
||||
|
||||
const done = $roadmapProgress?.done?.length || 0;
|
||||
|
||||
const hasProgress = done > 0;
|
||||
|
||||
if (!$totalRoadmapNodes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'fixed bottom-5 left-1/2 z-30 hidden -translate-x-1/2 transform animate-fade-slide-up overflow-hidden rounded-full bg-stone-900 px-4 py-2 text-center text-white shadow-2xl transition-all duration-300 sm:block'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn('block', {
|
||||
hidden: hasProgress,
|
||||
})}
|
||||
>
|
||||
<span className="mr-2 text-sm font-semibold uppercase text-yellow-400">
|
||||
Tip
|
||||
</span>
|
||||
<span className="text-sm text-gray-200">
|
||||
Right-click on a topic to mark it as done.{' '}
|
||||
<button
|
||||
data-popup="progress-help"
|
||||
className="cursor-pointer font-semibold text-yellow-500 underline"
|
||||
>
|
||||
Learn more.
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn('relative z-20 block text-sm', {
|
||||
hidden: !hasProgress,
|
||||
})}
|
||||
>
|
||||
<span className="relative -top-[0.45px] mr-2 text-xs font-medium uppercase text-yellow-400">
|
||||
Progress
|
||||
</span>
|
||||
<span>{done}</span> of <span>{$totalRoadmapNodes}</span> Done
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="absolute bottom-0 left-0 top-0 z-10 bg-stone-700"
|
||||
style={{
|
||||
width: `${(done / $totalRoadmapNodes) * 100}%`,
|
||||
}}
|
||||
></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
renderTopicProgress,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import type { ResourceProgressType, ResourceType } from '../../lib/resource-progress';
|
||||
import type {
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import {replaceChildren} from "../../lib/dom.ts";
|
||||
import { replaceChildren } from '../../lib/dom.ts';
|
||||
|
||||
export class Renderer {
|
||||
resourceId: string;
|
||||
@@ -95,7 +98,7 @@ export class Renderer {
|
||||
.then(() => {
|
||||
return renderResourceProgress(
|
||||
this.resourceType as ResourceType,
|
||||
this.resourceId
|
||||
this.resourceId,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -114,19 +117,6 @@ export class Renderer {
|
||||
});
|
||||
}
|
||||
|
||||
trackVisit() {
|
||||
if (!isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType,
|
||||
}).then(() => null);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
onDOMLoaded() {
|
||||
if (!this.prepareConfig()) {
|
||||
return;
|
||||
@@ -135,15 +125,13 @@ export class Renderer {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const roadmapType = urlParams.get('r');
|
||||
|
||||
this.trackVisit();
|
||||
|
||||
if (roadmapType) {
|
||||
this.switchRoadmap(`/${roadmapType}.json`);
|
||||
} else {
|
||||
this.jsonToSvg(
|
||||
this.resourceType === 'roadmap'
|
||||
? `/${this.resourceId}.json`
|
||||
: `/best-practices/${this.resourceId}.json`
|
||||
: `/best-practices/${this.resourceId}.json`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -183,7 +171,7 @@ export class Renderer {
|
||||
resourceType: this.resourceType as ResourceType,
|
||||
topicId,
|
||||
},
|
||||
newStatus
|
||||
newStatus,
|
||||
)
|
||||
.then(() => {
|
||||
renderTopicProgress(topicId, newStatus);
|
||||
@@ -215,9 +203,14 @@ export class Renderer {
|
||||
|
||||
const isCurrentStatusDone = targetGroup.classList.contains('done');
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
|
||||
if (normalizedGroupId.startsWith('ext_link:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusDone ? 'done' : 'pending'
|
||||
!isCurrentStatusDone ? 'done' : 'pending',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -243,9 +236,12 @@ export class Renderer {
|
||||
action: `${this.resourceType} / ${this.resourceId}`,
|
||||
label: externalLink,
|
||||
});
|
||||
|
||||
window.open(`https://${externalLink}`);
|
||||
} else {
|
||||
window.location.href = `https://${externalLink}`;
|
||||
}
|
||||
|
||||
window.open(`https://${externalLink}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -265,7 +261,7 @@ export class Renderer {
|
||||
resourceType: this.resourceType,
|
||||
resourceId: this.resourceId,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -280,7 +276,7 @@ export class Renderer {
|
||||
e.preventDefault();
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusLearning ? 'learning' : 'pending'
|
||||
!isCurrentStatusLearning ? 'learning' : 'pending',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -289,7 +285,7 @@ export class Renderer {
|
||||
e.preventDefault();
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusSkipped ? 'skipped' : 'pending'
|
||||
!isCurrentStatusSkipped ? 'skipped' : 'pending',
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -302,7 +298,7 @@ export class Renderer {
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import UserPlusIcon from '../../icons/user-plus.svg';
|
||||
import CopyIcon from '../../icons/copy.svg';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import { CopyIcon, UserPlus2 } from 'lucide-react';
|
||||
|
||||
type EmptyFriendsProps = {
|
||||
befriendUrl: string;
|
||||
@@ -13,14 +12,12 @@ export function EmptyFriends(props: EmptyFriendsProps) {
|
||||
return (
|
||||
<div className="rounded-md">
|
||||
<div className="mx-auto flex flex-col items-center p-7 text-center">
|
||||
<img
|
||||
alt="no friends"
|
||||
src={UserPlusIcon.src}
|
||||
className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]"
|
||||
/>
|
||||
<UserPlus2 className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
|
||||
|
||||
<h2 className="text-lg font-bold sm:text-xl">Invite your Friends</h2>
|
||||
<p className="mb-4 mt-1 max-w-[400px] text-sm leading-relaxed text-gray-500">
|
||||
Share the unique link below with your friends to track their skills and progress.
|
||||
Share the unique link below with your friends to track their skills
|
||||
and progress.
|
||||
</p>
|
||||
|
||||
<div className="flex w-full max-w-[352px] items-center justify-center gap-2 rounded-lg border-2 p-1 text-sm">
|
||||
@@ -44,7 +41,8 @@ export function EmptyFriends(props: EmptyFriendsProps) {
|
||||
copyText(befriendUrl);
|
||||
}}
|
||||
>
|
||||
<img src={CopyIcon.src} className="h-4 w-4" alt="Invite Friends" />
|
||||
<CopyIcon className="mr-1 h-4 w-4" />
|
||||
|
||||
{isCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,10 @@ import type { FriendshipStatus } from '../Befriend';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { EmptyFriends } from './EmptyFriends';
|
||||
import { FriendProgressItem } from './FriendProgressItem';
|
||||
import UserIcon from '../../icons/user.svg';
|
||||
import { UserProgressModal } from '../UserProgress/UserProgressModal';
|
||||
import { InviteFriendPopup } from './InviteFriendPopup';
|
||||
import { UserCustomProgressModal } from '../UserProgress/UserCustomProgressModal';
|
||||
import { UserIcon } from 'lucide-react';
|
||||
|
||||
type FriendResourceProgress = {
|
||||
updatedAt: string;
|
||||
@@ -64,7 +64,7 @@ export function FriendsPage() {
|
||||
|
||||
async function loadFriends() {
|
||||
const { response, error } = await httpGet<ListFriendsResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends`,
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -89,15 +89,15 @@ export function FriendsPage() {
|
||||
const befriendUrl = `${baseUrl}/befriend?u=${user?.id}`;
|
||||
|
||||
const selectedGroupingType = groupingTypes.find(
|
||||
(grouping) => grouping.value === selectedGrouping
|
||||
(grouping) => grouping.value === selectedGrouping,
|
||||
);
|
||||
|
||||
const filteredFriends = friends.filter((friend) =>
|
||||
selectedGroupingType?.statuses.includes(friend.status)
|
||||
const filteredFriends = friends.filter(
|
||||
(friend) => selectedGroupingType?.statuses.includes(friend.status),
|
||||
);
|
||||
|
||||
const receivedRequests = friends.filter(
|
||||
(friend) => friend.status === 'received'
|
||||
(friend) => friend.status === 'received',
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -203,11 +203,8 @@ export function FriendsPage() {
|
||||
|
||||
{filteredFriends.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<img
|
||||
src={UserIcon.src}
|
||||
alt="Empty Friends"
|
||||
className="mb-3 w-12 opacity-20"
|
||||
/>
|
||||
<UserIcon size={'60px'} className="mb-3 w-12 opacity-20" />
|
||||
|
||||
<h2 className="text-lg font-semibold">
|
||||
{selectedGrouping === 'active' && 'No friends yet'}
|
||||
{selectedGrouping === 'sent' && 'No requests sent'}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import CopyIcon from '../../icons/copy.svg';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import { CopyIcon } from 'lucide-react';
|
||||
|
||||
type InviteFriendPopupProps = {
|
||||
befriendUrl: string;
|
||||
@@ -54,11 +54,7 @@ export function InviteFriendPopup(props: InviteFriendPopupProps) {
|
||||
copyText(befriendUrl);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={CopyIcon.src}
|
||||
className="h-4 w-4"
|
||||
alt="Invite Friends"
|
||||
/>
|
||||
<CopyIcon className="mr-1 h-4 w-4" />
|
||||
{isCopied ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
53
src/components/GenerateRoadmap/AIRoadmapAlert.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { BadgeCheck, Telescope, Wand } from 'lucide-react';
|
||||
|
||||
type AIRoadmapAlertProps = {
|
||||
isListing?: boolean;
|
||||
};
|
||||
|
||||
export function AIRoadmapAlert(props: AIRoadmapAlertProps) {
|
||||
const { isListing = false } = props;
|
||||
|
||||
return (
|
||||
<div className="mb-3 w-full rounded-xl bg-yellow-100 px-4 py-3 text-yellow-800">
|
||||
<h2 className="flex items-center text-base font-semibold text-yellow-800 sm:text-lg">
|
||||
AI Generated Roadmap{isListing ? 's' : ''}{' '}
|
||||
<span className="ml-1.5 rounded-md border border-yellow-500 bg-yellow-200 px-1.5 text-xs uppercase tracking-wide text-yellow-800">
|
||||
Beta
|
||||
</span>
|
||||
</h2>
|
||||
<p className="mb-2 mt-1">
|
||||
{isListing
|
||||
? 'These are AI generated roadmaps and are not verified by'
|
||||
: 'This is an AI generated roadmap and is not verified by'}{' '}
|
||||
<span className={'font-semibold'}>roadmap.sh</span>. We are currently in
|
||||
beta and working hard to improve the quality of the generated roadmaps.
|
||||
</p>
|
||||
<p className="mb-1.5 mt-2 flex flex-col gap-2 text-sm sm:flex-row">
|
||||
{isListing ? (
|
||||
<a
|
||||
href="/ai"
|
||||
className="flex items-center gap-1.5 rounded-md border border-yellow-600 px-2 py-1 text-yellow-700 transition-colors hover:bg-yellow-300 hover:text-yellow-800"
|
||||
>
|
||||
<Wand size={15} />
|
||||
Create your own Roadmap with AI
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href="/ai/explore"
|
||||
className="flex items-center gap-1.5 rounded-md border border-yellow-600 px-2 py-1 text-yellow-700 transition-colors hover:bg-yellow-300 hover:text-yellow-800"
|
||||
>
|
||||
<Telescope size={15} />
|
||||
Explore other AI Roadmaps
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="flex items-center gap-1.5 rounded-md border border-yellow-600 bg-yellow-200 px-2 py-1 text-yellow-800 transition-colors hover:bg-yellow-300"
|
||||
>
|
||||
<BadgeCheck size={15} />
|
||||
Visit Official Roadmaps
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/GenerateRoadmap/GenerateRoadmap.css
Normal file
@@ -0,0 +1,58 @@
|
||||
@font-face {
|
||||
font-family: 'balsamiq';
|
||||
src: url('/fonts/balsamiq.woff2');
|
||||
}
|
||||
|
||||
svg text tspan {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic'],
|
||||
svg > g[data-type='subtopic'],
|
||||
svg > g > g[data-type='link-item'],
|
||||
svg > g[data-type='button'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic']:hover > rect {
|
||||
fill: #d6d700;
|
||||
}
|
||||
|
||||
svg > g[data-type='subtopic']:hover > rect {
|
||||
fill: #f3c950;
|
||||
}
|
||||
svg > g[data-type='button']:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
svg .done rect {
|
||||
fill: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done text,
|
||||
svg .skipped text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic'].learning > rect + text,
|
||||
svg > g[data-type='topic'].done > rect + text {
|
||||
fill: black;
|
||||
}
|
||||
|
||||
svg > g[data-type='subtipic'].done > rect + text,
|
||||
svg > g[data-type='subtipic'].learning > rect + text {
|
||||
fill: #cbcbcb;
|
||||
}
|
||||
|
||||
svg .learning rect {
|
||||
fill: #dad1fd !important;
|
||||
}
|
||||
svg .learning text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
svg .skipped rect {
|
||||
fill: #496b69 !important;
|
||||
}
|
||||
590
src/components/GenerateRoadmap/GenerateRoadmap.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
import {
|
||||
type FormEvent,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import './GenerateRoadmap.css';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator';
|
||||
import { renderFlowJSON } from '../../../editor/renderer/renderer';
|
||||
import { replaceChildren } from '../../lib/dom';
|
||||
import { readAIRoadmapStream } from '../../helper/read-stream';
|
||||
import {
|
||||
getOpenAIKey,
|
||||
isLoggedIn,
|
||||
removeAuthToken,
|
||||
visitAIRoadmap,
|
||||
} from '../../lib/jwt';
|
||||
import { RoadmapSearch } from './RoadmapSearch.tsx';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { Ban, Cog, Download, PenSquare, Save, Wand } from 'lucide-react';
|
||||
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
|
||||
import { httpGet, httpPost } from '../../lib/http.ts';
|
||||
import { pageProgressMessage } from '../../stores/page.ts';
|
||||
import {
|
||||
deleteUrlParam,
|
||||
getUrlParams,
|
||||
setUrlParams,
|
||||
} from '../../lib/browser.ts';
|
||||
import { downloadGeneratedRoadmapImage } from '../../helper/download-image.ts';
|
||||
import { showLoginPopup } from '../../lib/popup.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
|
||||
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
|
||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||
|
||||
export type GetAIRoadmapLimitResponse = {
|
||||
used: number;
|
||||
limit: number;
|
||||
topicUsed: number;
|
||||
topicLimit: number;
|
||||
};
|
||||
|
||||
const ROADMAP_ID_REGEX = new RegExp('@ROADMAPID:(\\w+)@');
|
||||
|
||||
export type RoadmapNodeDetails = {
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
targetGroup?: SVGElement;
|
||||
nodeTitle?: string;
|
||||
parentTitle?: string;
|
||||
};
|
||||
|
||||
export function getNodeDetails(
|
||||
svgElement: SVGElement,
|
||||
): RoadmapNodeDetails | null {
|
||||
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
|
||||
|
||||
const nodeId = targetGroup?.dataset?.nodeId;
|
||||
const nodeType = targetGroup?.dataset?.type;
|
||||
const nodeTitle = targetGroup?.dataset?.title;
|
||||
const parentTitle = targetGroup?.dataset?.parentTitle;
|
||||
if (!nodeId || !nodeType) return null;
|
||||
|
||||
return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle };
|
||||
}
|
||||
|
||||
export const allowedClickableNodeTypes = [
|
||||
'topic',
|
||||
'subtopic',
|
||||
'button',
|
||||
'link-item',
|
||||
];
|
||||
|
||||
type GetAIRoadmapResponse = {
|
||||
id: string;
|
||||
term: string;
|
||||
title: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export function GenerateRoadmap() {
|
||||
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { id: roadmapId } = getUrlParams() as { id: string };
|
||||
const toast = useToast();
|
||||
|
||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [roadmapTerm, setRoadmapTerm] = useState('');
|
||||
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
|
||||
const [currentRoadmap, setCurrentRoadmap] =
|
||||
useState<GetAIRoadmapResponse | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<RoadmapNodeDetails | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [roadmapLimit, setRoadmapLimit] = useState(0);
|
||||
const [roadmapLimitUsed, setRoadmapLimitUsed] = useState(0);
|
||||
const [roadmapTopicLimit, setRoadmapTopicLimit] = useState(0);
|
||||
const [roadmapTopicLimitUsed, setRoadmapTopicLimitUsed] = useState(0);
|
||||
const [isConfiguring, setIsConfiguring] = useState(false);
|
||||
|
||||
const openAPIKey = getOpenAIKey();
|
||||
|
||||
const renderRoadmap = async (roadmap: string) => {
|
||||
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
|
||||
const svg = await renderFlowJSON({ nodes, edges });
|
||||
if (roadmapContainerRef?.current) {
|
||||
replaceChildren(roadmapContainerRef?.current, svg);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTermRoadmap = async (term: string) => {
|
||||
setIsLoading(true);
|
||||
setHasSubmitted(true);
|
||||
|
||||
if (roadmapLimitUsed >= roadmapLimit) {
|
||||
toast.error('You have reached your limit of generating roadmaps');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteUrlParam('id');
|
||||
setCurrentRoadmap(null);
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ term }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
toast.error(data?.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
|
||||
// Logout user if token is invalid
|
||||
if (data.status === 401) {
|
||||
removeAuthToken();
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
setIsLoading(false);
|
||||
toast.error('Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
await readAIRoadmapStream(reader, {
|
||||
onStream: async (result) => {
|
||||
if (result.includes('@ROADMAPID')) {
|
||||
// @ROADMAPID: is a special token that we use to identify the roadmap
|
||||
// @ROADMAPID:1234@ is the format, we will remove the token and the id
|
||||
// and replace it with a empty string
|
||||
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
|
||||
setUrlParams({ id: roadmapId });
|
||||
result = result.replace(ROADMAP_ID_REGEX, '');
|
||||
setCurrentRoadmap({
|
||||
id: roadmapId,
|
||||
term: roadmapTerm,
|
||||
title: term,
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
|
||||
await renderRoadmap(result);
|
||||
},
|
||||
onStreamEnd: async (result) => {
|
||||
result = result.replace(ROADMAP_ID_REGEX, '');
|
||||
setGeneratedRoadmapContent(result);
|
||||
loadAIRoadmapLimit().finally(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!roadmapTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (roadmapTerm === currentRoadmap?.topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadTermRoadmap(roadmapTerm).finally(() => null);
|
||||
};
|
||||
|
||||
const saveAIRoadmap = async () => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('Redirecting to Editor');
|
||||
|
||||
const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmapContent);
|
||||
|
||||
const { response, error } = await httpPost<{
|
||||
roadmapId: string;
|
||||
}>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-save-ai-roadmap/${currentRoadmap?.id}`,
|
||||
{
|
||||
title: roadmapTerm,
|
||||
nodes: nodes.map((node) => ({
|
||||
...node,
|
||||
|
||||
// To reset the width and height of the node
|
||||
// so that it can be calculated based on the content in the editor
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
style: {
|
||||
...node.style,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
},
|
||||
})),
|
||||
edges,
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
pageProgressMessage.set('');
|
||||
return response.roadmapId;
|
||||
};
|
||||
|
||||
const downloadGeneratedRoadmapContent = async () => {
|
||||
pageProgressMessage.set('Downloading Roadmap');
|
||||
|
||||
const node = document.getElementById('roadmap-container');
|
||||
if (!node) {
|
||||
toast.error('Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadGeneratedRoadmapImage(roadmapTerm, node);
|
||||
pageProgressMessage.set('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Something went wrong');
|
||||
}
|
||||
};
|
||||
|
||||
const loadAIRoadmapLimit = async () => {
|
||||
const { response, error } = await httpGet<GetAIRoadmapLimitResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap-limit`,
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
const { limit, used, topicLimit, topicUsed } = response;
|
||||
setRoadmapLimit(limit);
|
||||
setRoadmapLimitUsed(used);
|
||||
setRoadmapTopicLimit(topicLimit);
|
||||
setRoadmapTopicLimitUsed(topicUsed);
|
||||
};
|
||||
|
||||
const loadAIRoadmap = async (roadmapId: string) => {
|
||||
pageProgressMessage.set('Loading Roadmap');
|
||||
|
||||
const { response, error } = await httpGet<{
|
||||
topic: string;
|
||||
data: string;
|
||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { term, title, data } = response;
|
||||
await renderRoadmap(data);
|
||||
|
||||
setCurrentRoadmap({
|
||||
id: roadmapId,
|
||||
title: title,
|
||||
term: term,
|
||||
data,
|
||||
});
|
||||
|
||||
setRoadmapTerm(title);
|
||||
setGeneratedRoadmapContent(data);
|
||||
visitAIRoadmap(roadmapId);
|
||||
};
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>) => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.target as SVGElement;
|
||||
const { nodeId, nodeType, targetGroup, nodeTitle, parentTitle } =
|
||||
getNodeDetails(target) || {};
|
||||
if (
|
||||
!nodeId ||
|
||||
!nodeType ||
|
||||
!allowedClickableNodeTypes.includes(nodeType) ||
|
||||
!nodeTitle
|
||||
)
|
||||
return;
|
||||
|
||||
if (nodeType === 'button' || nodeType === 'link-item') {
|
||||
const link = targetGroup?.dataset?.link || '';
|
||||
const isExternalLink = link.startsWith('http');
|
||||
if (isExternalLink) {
|
||||
window.open(link, '_blank');
|
||||
} else {
|
||||
window.location.href = link;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedNode({
|
||||
nodeId,
|
||||
nodeType,
|
||||
nodeTitle,
|
||||
...(nodeType === 'subtopic' && { parentTitle }),
|
||||
});
|
||||
},
|
||||
[isLoading],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadAIRoadmapLimit().finally(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roadmapId || roadmapId === currentRoadmap?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasSubmitted(true);
|
||||
loadAIRoadmap(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, [roadmapId, currentRoadmap]);
|
||||
|
||||
if (!hasSubmitted) {
|
||||
return (
|
||||
<RoadmapSearch
|
||||
roadmapTerm={roadmapTerm}
|
||||
setRoadmapTerm={setRoadmapTerm}
|
||||
handleSubmit={handleSubmit}
|
||||
limit={roadmapLimit}
|
||||
limitUsed={roadmapLimitUsed}
|
||||
loadAIRoadmapLimit={loadAIRoadmapLimit}
|
||||
onLoadTerm={(term: string) => {
|
||||
setRoadmapTerm(term);
|
||||
loadTermRoadmap(term).finally(() => {});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`;
|
||||
const canGenerateMore = roadmapLimitUsed < roadmapLimit;
|
||||
const isLoggedInUser = isLoggedIn();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isConfiguring && (
|
||||
<OpenAISettings
|
||||
onClose={() => {
|
||||
setIsConfiguring(false);
|
||||
loadAIRoadmapLimit().finally(() => null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedNode && currentRoadmap && !isLoading && (
|
||||
<RoadmapTopicDetail
|
||||
nodeId={selectedNode.nodeId}
|
||||
nodeType={selectedNode.nodeType}
|
||||
nodeTitle={selectedNode.nodeTitle}
|
||||
parentTitle={selectedNode.parentTitle}
|
||||
onConfigureOpenAI={() => {
|
||||
setSelectedNode(null);
|
||||
setIsConfiguring(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setSelectedNode(null);
|
||||
loadAIRoadmapLimit().finally(() => {});
|
||||
}}
|
||||
roadmapId={currentRoadmap?.id || ''}
|
||||
topicLimit={roadmapTopicLimit}
|
||||
topicLimitUsed={roadmapTopicLimitUsed}
|
||||
onTopicContentGenerateComplete={async () => {
|
||||
await loadAIRoadmapLimit();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="flex flex-grow flex-col bg-gray-100">
|
||||
<div className="flex items-center justify-center border-b bg-white py-3 sm:py-6">
|
||||
{isLoading && (
|
||||
<span className="flex items-center gap-2 rounded-full bg-black px-3 py-1 text-white">
|
||||
<Spinner isDualRing={false} innerFill={'white'} />
|
||||
Generating roadmap ..
|
||||
</span>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="container flex flex-grow flex-col items-center">
|
||||
<AIRoadmapAlert />
|
||||
<div className="mt-2 flex w-full flex-col items-start justify-between gap-2 text-sm sm:flex-row sm:items-center sm:gap-0">
|
||||
<span>
|
||||
<span
|
||||
className={cn(
|
||||
'mr-0.5 inline-block rounded-xl border px-1.5 text-center text-sm tabular-nums text-gray-800',
|
||||
{
|
||||
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
|
||||
!roadmapLimit,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{roadmapLimitUsed} of {roadmapLimit}
|
||||
</span>{' '}
|
||||
roadmaps generated.
|
||||
</span>
|
||||
{!isLoggedInUser && (
|
||||
<button
|
||||
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
||||
onClick={showLoginPopup}
|
||||
>
|
||||
Generate more by{' '}
|
||||
<span className="font-semibold">
|
||||
signing up (free, takes 2s)
|
||||
</span>{' '}
|
||||
or <span className="font-semibold">logging in</span>
|
||||
</button>
|
||||
)}
|
||||
{isLoggedInUser && !openAPIKey && (
|
||||
<button
|
||||
onClick={() => setIsConfiguring(true)}
|
||||
className="text-left rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
||||
>
|
||||
By-pass all limits by{' '}
|
||||
<span className="font-semibold">
|
||||
adding your own OpenAI API key
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLoggedInUser && openAPIKey && (
|
||||
<button
|
||||
onClick={() => setIsConfiguring(true)}
|
||||
className="flex flex-row items-center gap-1 rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
||||
>
|
||||
<Cog size={15} />
|
||||
Configure OpenAI key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="e.g. Try searching for Ansible or DevOps"
|
||||
className="flex-grow rounded-md border border-gray-400 px-3 py-2 transition-colors focus:border-black focus:outline-none"
|
||||
value={roadmapTerm}
|
||||
onInput={(e) =>
|
||||
setRoadmapTerm((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type={'submit'}
|
||||
className={cn(
|
||||
'flex min-w-[127px] flex-shrink-0 items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-white',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
disabled={
|
||||
!roadmapLimit ||
|
||||
!roadmapTerm ||
|
||||
roadmapLimitUsed >= roadmapLimit ||
|
||||
roadmapTerm === currentRoadmap?.term
|
||||
}
|
||||
>
|
||||
{roadmapLimit > 0 && canGenerateMore && (
|
||||
<>
|
||||
<Wand size={20} />
|
||||
Generate
|
||||
</>
|
||||
)}
|
||||
|
||||
{roadmapLimit === 0 && <span>Please wait..</span>}
|
||||
|
||||
{roadmapLimit > 0 && !canGenerateMore && (
|
||||
<span className="flex items-center text-sm">
|
||||
<Ban size={15} className="mr-2" />
|
||||
Limit reached
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-yellow-400 py-1.5 pl-2.5 pr-3 text-xs font-medium transition-opacity duration-300 hover:bg-yellow-500 sm:text-sm"
|
||||
onClick={downloadGeneratedRoadmapContent}
|
||||
>
|
||||
<Download size={15} />
|
||||
Download
|
||||
</button>
|
||||
{roadmapId && (
|
||||
<ShareRoadmapButton
|
||||
description={`Check out ${roadmapTerm} roadmap I generated on roadmap.sh`}
|
||||
pageUrl={pageUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:text-sm"
|
||||
onClick={async () => {
|
||||
const roadmapId = await saveAIRoadmap();
|
||||
if (roadmapId) {
|
||||
window.location.href = `/r?id=${roadmapId}`;
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Save size={15} />
|
||||
<span className="hidden sm:inline">
|
||||
Save and Start Learning
|
||||
</span>
|
||||
<span className="inline sm:hidden">Start Learning</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="hidden items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:inline-flex sm:text-sm"
|
||||
onClick={async () => {
|
||||
const roadmapId = await saveAIRoadmap();
|
||||
if (roadmapId) {
|
||||
window.open(
|
||||
`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`,
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<PenSquare size={15} />
|
||||
Edit in Editor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={roadmapContainerRef}
|
||||
id="roadmap-container"
|
||||
onClick={handleNodeClick}
|
||||
className="relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
168
src/components/GenerateRoadmap/OpenAISettings.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Modal } from '../Modal.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
deleteOpenAIKey,
|
||||
getOpenAIKey,
|
||||
saveOpenAIKey,
|
||||
} from '../../lib/jwt.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { CloseIcon } from '../ReactIcons/CloseIcon.tsx';
|
||||
import { useToast } from '../../hooks/use-toast.ts';
|
||||
import { httpPost } from '../../lib/http.ts';
|
||||
|
||||
type OpenAISettingsProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function OpenAISettings(props: OpenAISettingsProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const [defaultOpenAIKey, setDefaultOpenAIKey] = useState('');
|
||||
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [openaiApiKey, setOpenaiApiKey] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const apiKey = getOpenAIKey();
|
||||
setOpenaiApiKey(apiKey || '');
|
||||
setDefaultOpenAIKey(apiKey || '');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<div className="p-5">
|
||||
<h2 className="text-xl font-medium text-gray-800">OpenAI Settings</h2>
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-700">
|
||||
AI Roadmap generator uses OpenAI's GPT-4 model to generate roadmaps.
|
||||
</p>
|
||||
|
||||
<p className="mt-2">
|
||||
<a
|
||||
className="font-semibold underline underline-offset-2"
|
||||
href={'https://platform.openai.com/signup'}
|
||||
target="_blank"
|
||||
>
|
||||
Create an account on OpenAI
|
||||
</a>{' '}
|
||||
and enter your API key below to enable the AI Roadmap generator
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="mt-4"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setHasError(false);
|
||||
|
||||
const normalizedKey = openaiApiKey.trim();
|
||||
if (!normalizedKey) {
|
||||
deleteOpenAIKey();
|
||||
toast.success('OpenAI API key removed');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizedKey.startsWith('sk-')) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-validate-openai-key`,
|
||||
{
|
||||
key: normalizedKey,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setHasError(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the API key to cookies
|
||||
saveOpenAIKey(normalizedKey);
|
||||
toast.success('OpenAI API key saved');
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="openai-api-key"
|
||||
id="openai-api-key"
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-800 transition-colors focus:border-black focus:outline-none',
|
||||
{
|
||||
'border-red-500 bg-red-100 focus:border-red-500': hasError,
|
||||
},
|
||||
)}
|
||||
placeholder="Enter your OpenAI API key"
|
||||
value={openaiApiKey}
|
||||
onChange={(e) => {
|
||||
setHasError(false);
|
||||
setOpenaiApiKey((e.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
|
||||
{openaiApiKey && (
|
||||
<button
|
||||
type={'button'}
|
||||
onClick={() => {
|
||||
setOpenaiApiKey('');
|
||||
}}
|
||||
className="absolute right-2 top-1/2 flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center rounded-full bg-gray-400 text-white hover:bg-gray-600"
|
||||
>
|
||||
<CloseIcon className="h-[13px] w-[13px] stroke-[3.5]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasError && (
|
||||
<p className="mt-2 text-sm text-red-500">
|
||||
Please enter a valid OpenAI API key
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
className={
|
||||
'mt-2 w-full rounded-md bg-gray-700 px-4 py-2 text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
{!isLoading && 'Save'}
|
||||
{isLoading && 'Validating ..'}
|
||||
</button>
|
||||
{!defaultOpenAIKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
className="mt-1 w-full rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-black hover:bg-red-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{defaultOpenAIKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
deleteOpenAIKey();
|
||||
onClose();
|
||||
toast.success('OpenAI API key removed');
|
||||
}}
|
||||
className="mt-1 w-full rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-black hover:bg-red-700"
|
||||
>
|
||||
Reset to Default Key
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
188
src/components/GenerateRoadmap/RoadmapSearch.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Ban,
|
||||
CircleFadingPlus,
|
||||
Cog,
|
||||
Telescope,
|
||||
Wand,
|
||||
} from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { getOpenAIKey, isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { useState } from 'react';
|
||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||
|
||||
type RoadmapSearchProps = {
|
||||
roadmapTerm: string;
|
||||
setRoadmapTerm: (topic: string) => void;
|
||||
handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
loadAIRoadmapLimit: () => void;
|
||||
onLoadTerm: (topic: string) => void;
|
||||
limit: number;
|
||||
limitUsed: number;
|
||||
};
|
||||
|
||||
export function RoadmapSearch(props: RoadmapSearchProps) {
|
||||
const {
|
||||
roadmapTerm,
|
||||
setRoadmapTerm,
|
||||
handleSubmit,
|
||||
limit = 0,
|
||||
limitUsed = 0,
|
||||
onLoadTerm,
|
||||
loadAIRoadmapLimit,
|
||||
} = props;
|
||||
|
||||
const canGenerateMore = limitUsed < limit;
|
||||
const [isConfiguring, setIsConfiguring] = useState(false);
|
||||
const openAPIKey = getOpenAIKey();
|
||||
|
||||
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow flex-col items-center justify-center px-4 py-6 sm:px-6">
|
||||
{isConfiguring && (
|
||||
<OpenAISettings
|
||||
onClose={() => {
|
||||
setIsConfiguring(false);
|
||||
loadAIRoadmapLimit();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 sm:text-lg">
|
||||
<span className="hidden sm:inline">
|
||||
Enter a topic and let the AI generate a roadmap for you
|
||||
</span>
|
||||
<span className="inline sm:hidden">
|
||||
Enter a topic to generate a roadmap
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="my-3 flex w-full max-w-[600px] flex-col items-center gap-3 sm:my-5">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
if (limit > 0 && canGenerateMore) {
|
||||
handleSubmit(e);
|
||||
} else {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="flex w-full flex-col gap-2 sm:flex-row"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Enter a topic to generate a roadmap for"
|
||||
className="w-full rounded-md border border-gray-400 px-3 py-2.5 transition-colors focus:border-black focus:outline-none"
|
||||
value={roadmapTerm}
|
||||
onInput={(e) =>
|
||||
setRoadmapTerm((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-w-[154px] flex-shrink-0 items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-white',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
disabled={!limit || !roadmapTerm || limitUsed >= limit}
|
||||
>
|
||||
{(!limit || canGenerateMore) && (
|
||||
<>
|
||||
<Wand size={20} />
|
||||
Generate
|
||||
</>
|
||||
)}
|
||||
|
||||
{limit > 0 && !canGenerateMore && (
|
||||
<span className="flex items-center text-base">
|
||||
<Ban size={15} className="mr-2" />
|
||||
Limit reached
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex flex-row items-center justify-center gap-2 flex-wrap">
|
||||
{randomTerms.map((term) => (
|
||||
<button
|
||||
key={term}
|
||||
disabled={!limit || !canGenerateMore}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onLoadTerm(term);
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-sm transition-colors hover:border-black hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{term} <ArrowUpRight size={17} />
|
||||
</button>
|
||||
))}
|
||||
<a
|
||||
href="/ai/explore"
|
||||
className="flex items-center gap-1.5 rounded-full border border-black bg-gray-700 px-2 py-0.5 text-sm text-white transition-colors hover:border-black hover:bg-black"
|
||||
>
|
||||
Explore AI Roadmaps <Telescope size={17} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 flex flex-col items-center gap-4">
|
||||
<p className="text-gray-500">
|
||||
You have generated{' '}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block min-w-[50px] rounded-xl border px-1.5 text-center text-sm tabular-nums text-gray-800',
|
||||
{
|
||||
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
|
||||
!limit,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{limitUsed} of {limit}
|
||||
</span>{' '}
|
||||
roadmaps.
|
||||
</p>
|
||||
<p className="flex min-h-[26px] items-center text-sm">
|
||||
{limit > 0 && !isLoggedIn() && (
|
||||
<button
|
||||
onClick={showLoginPopup}
|
||||
className="rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
||||
>
|
||||
Generate more by{' '}
|
||||
<span className="font-semibold">
|
||||
signing up (free and takes 2 seconds)
|
||||
</span>{' '}
|
||||
or <span className="font-semibold">logging in</span>
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
<p className="-mt-[45px] flex min-h-[26px] items-center text-sm">
|
||||
{limit > 0 && isLoggedIn() && !openAPIKey && (
|
||||
<button
|
||||
onClick={() => setIsConfiguring(true)}
|
||||
className="rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
||||
>
|
||||
By-pass all limits by{' '}
|
||||
<span className="font-semibold">
|
||||
adding your own OpenAI API key
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{limit > 0 && isLoggedIn() && openAPIKey && (
|
||||
<button
|
||||
onClick={() => setIsConfiguring(true)}
|
||||
className="flex flex-row items-center gap-1 rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
||||
>
|
||||
<Cog size={15} />
|
||||
Configure OpenAI key
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { markdownToHtml } from '../../lib/markdown';
|
||||
import { Ban, Cog, FileText, X } from 'lucide-react';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import type { RoadmapNodeDetails } from './GenerateRoadmap';
|
||||
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import { readAIRoadmapContentStream } from '../../helper/read-stream';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||
|
||||
type RoadmapTopicDetailProps = RoadmapNodeDetails & {
|
||||
onClose?: () => void;
|
||||
roadmapId: string;
|
||||
topicLimitUsed: number;
|
||||
topicLimit: number;
|
||||
onTopicContentGenerateComplete?: () => void;
|
||||
onConfigureOpenAI?: () => void;
|
||||
};
|
||||
|
||||
export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
|
||||
const {
|
||||
onClose,
|
||||
roadmapId,
|
||||
nodeTitle,
|
||||
parentTitle,
|
||||
topicLimit,
|
||||
topicLimitUsed,
|
||||
onTopicContentGenerateComplete,
|
||||
onConfigureOpenAI,
|
||||
} = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [topicHtml, setTopicHtml] = useState('');
|
||||
|
||||
const topicRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const abortController = useMemo(() => new AbortController(), []);
|
||||
const generateAiRoadmapTopicContent = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
//
|
||||
// if (topicLimitUsed >= topicLimit) {
|
||||
// setError('Maximum limit reached');
|
||||
// setIsLoading(false);
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (!roadmapId || !nodeTitle) {
|
||||
setIsLoading(false);
|
||||
setError('Invalid roadmap id or node title');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap-content/${roadmapId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
nodeTitle,
|
||||
parentTitle,
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
setError(data?.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
|
||||
// Logout user if token is invalid
|
||||
if (data.status === 401) {
|
||||
removeAuthToken();
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
await readAIRoadmapContentStream(reader, {
|
||||
onStream: async (result) => {
|
||||
setTopicHtml(markdownToHtml(result, false));
|
||||
},
|
||||
});
|
||||
onTopicContentGenerateComplete?.();
|
||||
};
|
||||
|
||||
// Close the topic detail when user clicks outside the topic detail
|
||||
useOutsideClick(topicRef, () => {
|
||||
onClose?.();
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose?.();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicRef?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
topicRef?.current?.focus();
|
||||
generateAiRoadmapTopicContent().finally(() => {});
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasContent = topicHtml?.length > 0;
|
||||
const openAIKey = getOpenAIKey();
|
||||
|
||||
return (
|
||||
<div className={'relative z-50'}>
|
||||
<div
|
||||
ref={topicRef}
|
||||
tabIndex={0}
|
||||
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row">
|
||||
<span>
|
||||
<span
|
||||
className={cn(
|
||||
'mr-0.5 inline-block rounded-xl border px-1.5 text-center text-sm tabular-nums text-gray-800',
|
||||
{
|
||||
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
|
||||
!topicLimit,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{topicLimitUsed} of {topicLimit}
|
||||
</span>{' '}
|
||||
topics generated
|
||||
</span>
|
||||
{!isLoggedIn() && (
|
||||
<button
|
||||
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
||||
onClick={showLoginPopup}
|
||||
>
|
||||
Generate more by <span className="font-semibold">logging in</span>
|
||||
</button>
|
||||
)}
|
||||
{isLoggedIn() && !openAIKey && (
|
||||
<button
|
||||
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
||||
onClick={onConfigureOpenAI}
|
||||
>
|
||||
By-pass all limits by{' '}
|
||||
<span className="font-semibold">adding your own OpenAI Key</span>
|
||||
</button>
|
||||
)}
|
||||
{isLoggedIn() && openAIKey && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
||||
onClick={onConfigureOpenAI}
|
||||
>
|
||||
<Cog className="-mt-0.5 inline-block h-4 w-4" />
|
||||
Configure OpenAI Key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mt-6 flex w-full justify-center">
|
||||
<Spinner
|
||||
outerFill="#d1d5db"
|
||||
className="h-6 w-6 sm:h-12 sm:w-12"
|
||||
innerFill="#2563eb"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<button
|
||||
type="button"
|
||||
id="close-topic"
|
||||
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasContent ? (
|
||||
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
|
||||
<div
|
||||
id="topic-content"
|
||||
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[calc(100%-38px)] flex-col items-center justify-center">
|
||||
<FileText className="h-16 w-16 text-gray-300" />
|
||||
<p className="mt-2 text-lg font-medium text-gray-500">
|
||||
Empty Content
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{!isLoading && error && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
id="close-topic"
|
||||
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<Ban className="h-16 w-16 text-red-500" />
|
||||
<p className="mt-2 text-lg font-medium text-red-500">{error}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/components/GetStarted/RoadmapCard.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ExternalLink, Globe2, type LucideIcon } from 'lucide-react';
|
||||
|
||||
type RoadmapCardProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
icon2?: LucideIcon;
|
||||
link: string;
|
||||
isUpcoming?: boolean;
|
||||
};
|
||||
export function RoadmapCard(props: RoadmapCardProps) {
|
||||
const {
|
||||
isUpcoming,
|
||||
link,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
icon2: Icon2,
|
||||
} = props;
|
||||
|
||||
if (isUpcoming) {
|
||||
return (
|
||||
<div className="group relative block rounded-xl border border-gray-300 bg-gradient-to-br from-gray-100 to-gray-50 p-5 overflow-hidden">
|
||||
<div className="mb-2 sm:mb-5 flex flex-row items-center">
|
||||
<div className="flex h-7 w-7 sm:h-9 sm:w-9 items-center justify-center rounded-full bg-gray-900 text-white">
|
||||
<Icon className="h-3 sm:h-5" />
|
||||
</div>
|
||||
{Icon2 && (
|
||||
<>
|
||||
<span className="mx-2 text-gray-400">+</span>
|
||||
<div className="flex h-7 w-7 sm:h-9 sm:w-9 items-center justify-center rounded-full bg-gray-900 text-white">
|
||||
<Icon2 className="h-3 sm:h-5" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="mb-0.5 block text-lg sm:text-xl font-semibold sm:mb-2">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">{description}</span>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-100/70">
|
||||
<span className="text-sm bg-black rounded-lg text-white font-semibold py-1 px-2 -rotate-45 transform">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link}
|
||||
target={'_blank'}
|
||||
className="group relative block rounded-xl border border-gray-300 bg-gradient-to-br from-gray-100 to-gray-50
|
||||
p-3.5 sm:p-5 transition-colors duration-200 ease-in-out hover:cursor-pointer hover:border-black/30 hover:bg-gray-50/70 hover:shadow-sm"
|
||||
>
|
||||
<div className="mb-2 sm:mb-5 flex flex-row items-center">
|
||||
<div className="flex h-7 w-7 sm:h-9 sm:w-9 items-center justify-center rounded-full bg-gray-900 text-white">
|
||||
<Icon className="h-4 sm:h-5" />
|
||||
</div>
|
||||
{Icon2 && (
|
||||
<>
|
||||
<span className="mx-2 text-gray-400">+</span>
|
||||
<div className="flex h-7 w-7 sm:h-9 sm:w-9 items-center justify-center rounded-full bg-gray-900 text-white">
|
||||
<Icon2 className="h-4 sm:h-5" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="lucide lucide-external-link absolute right-2 top-2 h-4 text-gray-300 transition group-hover:text-gray-700" />
|
||||
<span className="mb-0 block text-lg sm:text-xl font-semibold sm:mb-2">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">{description}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
63
src/components/GetStarted/RoadmapMultiCard.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
type RoadmapMultiCardProps = {
|
||||
roadmaps: {
|
||||
title: string;
|
||||
link: string;
|
||||
}[];
|
||||
description: string;
|
||||
secondaryRoadmaps?: {
|
||||
title: string;
|
||||
link: string;
|
||||
}[];
|
||||
secondaryDescription?: string;
|
||||
};
|
||||
export function RoadmapMultiCard(props: RoadmapMultiCardProps) {
|
||||
const { roadmaps, description, secondaryRoadmaps, secondaryDescription } = props;
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col overflow-hidden rounded-xl border border-gray-300 bg-gradient-to-br from-gray-100
|
||||
to-gray-50 ease-in-out"
|
||||
>
|
||||
<div className="flex flex-col divide-y">
|
||||
{roadmaps.map((roadmap, index) => (
|
||||
<a
|
||||
target={'_blank'}
|
||||
key={index}
|
||||
href={roadmap.link}
|
||||
className="group text-sm sm:text-base flex w-full items-center justify-between gap-2 bg-gradient-to-br from-gray-100 to-gray-50 px-4 sm:px-5 py-2 transition-colors duration-200"
|
||||
>
|
||||
{roadmap.title}
|
||||
<ExternalLink className="lucide lucide-external-link h-4 text-gray-300 transition group-hover:text-gray-700" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="flex-grow bg-gray-200/70 p-4 sm:p-5 text-sm text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{secondaryRoadmaps && (
|
||||
<div className="flex flex-col divide-y">
|
||||
{secondaryRoadmaps.map((roadmap, index) => (
|
||||
<a
|
||||
target={'_blank'}
|
||||
key={index}
|
||||
href={roadmap.link}
|
||||
className="group text-sm sm:text-base flex w-full items-center justify-between gap-2 bg-gradient-to-br from-gray-100 to-gray-50 px-5 py-2 transition-colors duration-200"
|
||||
>
|
||||
{roadmap.title}
|
||||
<ExternalLink className="lucide lucide-external-link h-4 text-gray-300 transition group-hover:text-gray-700" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{secondaryDescription && (
|
||||
<p className="flex-grow bg-gray-200/70 p-4 sm:p-5 text-sm text-gray-500">
|
||||
{secondaryDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/GetStarted/RoleRoadmaps.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { SectionBadge } from './SectionBadge.tsx';
|
||||
|
||||
type RoleRoadmapsProps = {
|
||||
badge: string;
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function RoleRoadmaps(props: RoleRoadmapsProps) {
|
||||
const { badge, title, description, children } = props;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-b from-gray-100 to-white py-5 sm:py-8 md:py-12">
|
||||
<div className="container">
|
||||
<div className="text-left">
|
||||
<SectionBadge title={badge} />
|
||||
</div>
|
||||
<div className="my-4 sm:my-7 text-left">
|
||||
<h2 className="mb-1 text-xl sm:text-3xl font-semibold">{title}</h2>
|
||||
<p className="text-sm sm:text-base text-gray-500">{description}</p>
|
||||
|
||||
<div className="mt-4 sm:mt-7 grid sm:grid-cols-2 md:grid-cols-3 gap-3">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/components/GetStarted/SectionBadge.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
type SectionBadgeProps = {
|
||||
title: string;
|
||||
};
|
||||
export function SectionBadge(props: SectionBadgeProps) {
|
||||
const { title } = props;
|
||||
|
||||
return (
|
||||
<span className="rounded-full bg-black px-3 py-1 text-sm text-white">
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
31
src/components/GetStarted/TipItem.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
type TipItemProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
export function TipItem(props: TipItemProps) {
|
||||
const { title, description } = props;
|
||||
|
||||
const [isToggled, setIsToggled] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{!isToggled && (
|
||||
<div
|
||||
onClick={() => setIsToggled(true)}
|
||||
className="cursor-pointer rounded-lg sm:rounded-xl bg-black px-3 py-2 text-sm sm:text-base text-white"
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{isToggled && (
|
||||
<p
|
||||
className="rounded-lg sm:rounded-xl bg-gray-200 px-3 py-2 text-black text-sm sm:text-base"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,28 +6,32 @@ export interface Props {
|
||||
}
|
||||
|
||||
const { guide } = Astro.props;
|
||||
const { frontmatter } = guide;
|
||||
const { author } = frontmatter;
|
||||
const { frontmatter, author } = guide;
|
||||
---
|
||||
|
||||
<div class='bg-white border-b py-5 sm:py-12'>
|
||||
<div class='border-b bg-white py-5 sm:py-12'>
|
||||
<div class='container text-left sm:text-center'>
|
||||
<p
|
||||
class='text-gray-400 hidden sm:flex items-center justify-start sm:justify-center'
|
||||
class='hidden items-center justify-start text-gray-400 sm:flex sm:justify-center'
|
||||
>
|
||||
<a
|
||||
href={author.url}
|
||||
target='_blank'
|
||||
class='font-medium hover:text-gray-600 inline-flex items-center hover:underline'
|
||||
>
|
||||
<img
|
||||
alt={author.name}
|
||||
src={author.imageUrl}
|
||||
class='w-5 h-5 inline mr-2 rounded-full'
|
||||
/>
|
||||
{author.name}
|
||||
</a>
|
||||
<span class='mx-1.5'>·</span>
|
||||
{
|
||||
author?.frontmatter && (
|
||||
<>
|
||||
<a
|
||||
href={`/authors/${author.id}`}
|
||||
class='inline-flex items-center font-medium hover:text-gray-600 hover:underline'
|
||||
>
|
||||
<img
|
||||
alt={author.frontmatter.name}
|
||||
src={author.frontmatter.imageUrl}
|
||||
class='mr-2 inline h-5 w-5 rounded-full'
|
||||
/>
|
||||
{author.frontmatter.name}
|
||||
</a>
|
||||
<span class='mx-1.5'>·</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<span class='capitalize'>{frontmatter.type} Guide</span>
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a
|
||||
@@ -36,10 +40,10 @@ const { author } = frontmatter;
|
||||
target='_blank'>Improve this Guide</a
|
||||
>
|
||||
</p>
|
||||
<h1 class='text-2xl sm:text-5xl my-0 sm:my-3.5 font-bold'>
|
||||
<h1 class='my-0 text-2xl font-bold sm:my-3.5 sm:text-5xl'>
|
||||
{frontmatter.title}
|
||||
</h1>
|
||||
<p class='hidden sm:block text-gray-400 text-xl'>
|
||||
<p class='hidden text-xl text-gray-400 sm:block'>
|
||||
{frontmatter.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import type { GuideFileType } from "../lib/guide";
|
||||
import type { GuideFileType } from '../lib/guide';
|
||||
|
||||
export interface Props {
|
||||
guide: GuideFileType;
|
||||
@@ -11,30 +11,34 @@ const { frontmatter, id } = guide;
|
||||
|
||||
<a
|
||||
class:list={[
|
||||
"block no-underline py-2 group text-md items-center text-gray-600 hover:text-blue-600 flex justify-between border-b",
|
||||
'text-md group block flex items-center justify-between border-b py-2 text-gray-600 no-underline hover:text-blue-600',
|
||||
]}
|
||||
href={`/guides/${id}`}
|
||||
href={frontmatter.excludedBySlug
|
||||
? frontmatter.excludedBySlug
|
||||
: `/guides/${id}`}
|
||||
>
|
||||
<span class="group-hover:translate-x-2 transition-transform">
|
||||
<span
|
||||
class='text-sm transition-transform group-hover:translate-x-2 md:text-base'
|
||||
>
|
||||
{frontmatter.title}
|
||||
|
||||
{
|
||||
frontmatter.isNew && (
|
||||
<span class="bg-green-300 text-green-900 text-xs font-medium px-1.5 py-0.5 rounded-sm uppercase ml-1.5">
|
||||
<span class='ml-1.5 rounded-sm bg-green-300 px-1.5 py-0.5 text-xs font-medium uppercase text-green-900'>
|
||||
New
|
||||
<span class="hidden sm:inline">
|
||||
<span class='hidden sm:inline'>
|
||||
·
|
||||
{new Date(frontmatter.date).toLocaleString("default", {
|
||||
month: "long",
|
||||
{new Date(frontmatter.date).toLocaleString('default', {
|
||||
month: 'long',
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
<span class="capitalize text-gray-500 text-xs hidden sm:block">
|
||||
<span class='hidden text-xs capitalize text-gray-500 sm:block'>
|
||||
{frontmatter.type}
|
||||
</span>
|
||||
|
||||
<span class="text-gray-400 text-xs block sm:hidden"> »</span>
|
||||
<span class='block text-xs text-gray-400 sm:hidden'> »</span>
|
||||
</a>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
{progress.map((resource) => (
|
||||
<HeroRoadmap
|
||||
key={resource.resourceId}
|
||||
key={`${resource.resourceType}-${resource.resourceId}`}
|
||||
resourceId={resource.resourceId}
|
||||
resourceType={resource.resourceType}
|
||||
resourceTitle={resource.resourceTitle}
|
||||
|
||||
@@ -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-h3:mt-2 prose-img:mt-1'
|
||||
class='prose-xl prose-blockquote:font-normal prose container prose-code:bg-transparent prose-h2:text-3xl prose-h2:mt-10 prose-h2:mb-3 prose-h5:font-medium prose-h3:mt-2 prose-img:mt-1'
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,41 +1,56 @@
|
||||
---
|
||||
import { Menu } from 'lucide-react';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
import { NavigationDropdown } from '../NavigationDropdown';
|
||||
import { AccountDropdown } from './AccountDropdown';
|
||||
---
|
||||
|
||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||
<nav class='container flex items-center justify-between'>
|
||||
<a
|
||||
class='flex items-center text-lg font-medium text-white'
|
||||
href='/'
|
||||
aria-label='roadmap.sh'
|
||||
>
|
||||
<Icon icon='logo' />
|
||||
</a>
|
||||
<div class='flex items-center gap-5'>
|
||||
<a
|
||||
class='flex items-center text-lg font-medium text-white'
|
||||
href='/'
|
||||
aria-label='roadmap.sh'
|
||||
>
|
||||
<Icon icon='logo' />
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation items -->
|
||||
<ul class='hidden space-x-5 sm:flex sm:items-center'>
|
||||
<li>
|
||||
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/best-practices' class='text-gray-400 hover:text-white'
|
||||
>Best Practices</a
|
||||
>
|
||||
</li>
|
||||
<li class='hidden lg:inline'>
|
||||
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/teams' class='group relative text-blue-300 hover:text-white'>
|
||||
Teams
|
||||
<span
|
||||
class='ml-0.5 hidden rounded-sm border-black bg-blue-300 px-1 py-0.5 text-xs font-semibold uppercase text-black group-hover:bg-white md:inline'
|
||||
>
|
||||
New
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer nofollow'
|
||||
href='https://boards.greenhouse.io/insightmediagroupllc/jobs/4002116008'
|
||||
class='group inline sm:hidden relative !mr-2 text-blue-300 hover:text-white'
|
||||
>
|
||||
We're Hiring
|
||||
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
|
||||
></span>
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<span class='inline md:hidden absolute -right-[11px] top-0'>
|
||||
<!-- Desktop navigation items -->
|
||||
<div class='hidden space-x-5 sm:flex sm:items-center'>
|
||||
<NavigationDropdown client:load />
|
||||
<a href='/get-started' class='text-gray-400 hover:text-white'>
|
||||
Start Here
|
||||
</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'
|
||||
class='group relative !mr-2 text-blue-300 hover:text-white'
|
||||
>
|
||||
We're Hiring
|
||||
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
|
||||
@@ -45,17 +60,16 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<kbd
|
||||
<button
|
||||
data-command-menu
|
||||
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 sm:flex'
|
||||
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1.5 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 md:flex'
|
||||
>
|
||||
<Icon icon='search' class='mr-2 h-3 w-3' />
|
||||
<kbd class='mr-1 font-sans'>⌘</kbd><kbd class='font-sans'>K</kbd>
|
||||
</kbd>
|
||||
</li>
|
||||
</ul>
|
||||
<Icon icon='search' class='h-3 w-3' />
|
||||
<span class='ml-2'>Search</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class='hidden h-8 w-[172px] items-center justify-end gap-5 sm:flex'>
|
||||
<li data-guest-required class='hidden'>
|
||||
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { TOKEN_COOKIE_NAME, removeAuthToken } from '../../lib/jwt';
|
||||
|
||||
export function logout() {
|
||||
Cookies.remove(TOKEN_COOKIE_NAME, {
|
||||
path: '/',
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
removeAuthToken();
|
||||
|
||||
// Reloading will automatically redirect the user if required
|
||||
window.location.reload();
|
||||
|
||||
104
src/components/NavigationDropdown.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
BookOpenText,
|
||||
CheckSquare,
|
||||
FileQuestion,
|
||||
Menu,
|
||||
Shirt,
|
||||
Video,
|
||||
Waypoints,
|
||||
} from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { cn } from '../lib/classname.ts';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click.ts';
|
||||
|
||||
const links = [
|
||||
{
|
||||
link: '/roadmaps',
|
||||
label: 'Roadmaps',
|
||||
description: 'Step by step learning paths',
|
||||
Icon: Waypoints,
|
||||
},
|
||||
{
|
||||
link: '/best-practices',
|
||||
label: 'Best Practices',
|
||||
description: "Do's and don'ts",
|
||||
Icon: CheckSquare,
|
||||
},
|
||||
{
|
||||
link: '/questions',
|
||||
label: 'Questions',
|
||||
description: 'Test and Practice your knowledge',
|
||||
Icon: FileQuestion,
|
||||
},
|
||||
{
|
||||
link: '/guides',
|
||||
label: 'Guides',
|
||||
description: 'In-depth articles and tutorials',
|
||||
Icon: BookOpenText,
|
||||
},
|
||||
{
|
||||
link: 'https://youtube.com/@roadmapsh',
|
||||
label: 'Videos',
|
||||
description: 'Animated and interactive content',
|
||||
Icon: Video,
|
||||
isExternal: true,
|
||||
},
|
||||
{
|
||||
link: 'https://cottonbureau.com/people/roadmapsh',
|
||||
label: 'Shop',
|
||||
description: 'Get some cool swag',
|
||||
Icon: Shirt,
|
||||
isExternal: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function NavigationDropdown() {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center" ref={dropdownRef}>
|
||||
<button
|
||||
className={cn('text-gray-400 hover:text-white', {
|
||||
'text-white': isOpen,
|
||||
})}
|
||||
onClick={() => setIsOpen(true)}
|
||||
onMouseOver={() => setIsOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute pointer-events-none left-0 top-full z-[999] mt-2 w-48 min-w-[320px] -translate-y-1 rounded-lg bg-slate-800 py-2 opacity-0 shadow-xl transition-all duration-100',
|
||||
{
|
||||
'pointer-events-auto translate-y-2.5 opacity-100': isOpen,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{links.map((link) => (
|
||||
<a
|
||||
href={link.link}
|
||||
target={link.isExternal ? '_blank' : undefined}
|
||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||
key={link.link}
|
||||
className="group flex items-center gap-3 px-4 py-2.5 text-gray-400 transition-colors hover:bg-slate-700"
|
||||
>
|
||||
<span className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-slate-600 transition-colors group-hover:bg-slate-500 group-hover:text-slate-100">
|
||||
<link.Icon className="inline-block h-5 w-5" />
|
||||
</span>
|
||||
<span className="flex flex-col">
|
||||
<span className="font-medium text-slate-300 transition-colors group-hover:text-slate-100">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="text-sm">{link.description}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet, httpPatch, httpPost } from '../../lib/http';
|
||||
import { httpGet, httpPatch } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
|
||||
import XIcon from '../../icons/close-dark.svg';
|
||||
import AcceptIcon from '../../icons/accept.svg';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { AcceptIcon } from '../ReactIcons/AcceptIcon.tsx';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
interface NotificationList extends TeamMemberDocument {
|
||||
name: string;
|
||||
@@ -18,7 +18,7 @@ export function NotificationPage() {
|
||||
|
||||
const lostNotifications = async () => {
|
||||
const { error, response } = await httpGet<NotificationList[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation-list`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation-list`,
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
@@ -28,28 +28,37 @@ export function NotificationPage() {
|
||||
setNotifications(response);
|
||||
};
|
||||
|
||||
async function respondInvitation(status: 'accept' | 'reject', inviteId: string) {
|
||||
async function respondInvitation(
|
||||
status: 'accept' | 'reject',
|
||||
inviteId: string,
|
||||
) {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
const { response, error } = await httpPatch<{ teamId: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`, {
|
||||
status
|
||||
});
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`,
|
||||
{
|
||||
status,
|
||||
},
|
||||
);
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong')
|
||||
setIsLoading(false)
|
||||
setError(error?.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'accept') {
|
||||
window.location.href = `/team/progress?t=${response.teamId}`;
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('refresh-notification', {
|
||||
detail: {
|
||||
count: notifications.length - 1
|
||||
}
|
||||
}));
|
||||
setNotifications(notifications.filter((notification) => notification._id !== inviteId));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('refresh-notification', {
|
||||
detail: {
|
||||
count: notifications.length - 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
setNotifications(
|
||||
notifications.filter((notification) => notification._id !== inviteId),
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -66,15 +75,20 @@ export function NotificationPage() {
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Notification</h2>
|
||||
<p className="mt-2 text-gray-400">Manage your notifications</p>
|
||||
</div>
|
||||
{
|
||||
notifications.length === 0 && (
|
||||
<div className="flex items-center justify-center mt-6">
|
||||
<p className="text-gray-400">
|
||||
No notifications, you can <a href="/team/new" className="text-blue-500 underline hover:no-underline">create a team</a> and invite your friends to join.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{notifications.length === 0 && (
|
||||
<div className="mt-6 flex items-center justify-center">
|
||||
<p className="text-gray-400">
|
||||
No notifications, you can{' '}
|
||||
<a
|
||||
href="/team/new"
|
||||
className="text-blue-500 underline hover:no-underline"
|
||||
>
|
||||
create a team
|
||||
</a>{' '}
|
||||
and invite your friends to join.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification) => (
|
||||
<div className="flex items-center justify-between rounded-md border p-2">
|
||||
@@ -86,19 +100,21 @@ export function NotificationPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
|
||||
className="inline-flex rounded border p-1 hover:bg-gray-50 disabled:opacity-75"
|
||||
onClick={() => respondInvitation('accept', notification?._id!)}
|
||||
>
|
||||
<img src={AcceptIcon.src} className="h-4 w-4" />
|
||||
<AcceptIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
|
||||
className="inline-flex rounded border p-1 hover:bg-gray-50 disabled:opacity-75"
|
||||
onClick={() => respondInvitation('reject', notification?._id!)}
|
||||
>
|
||||
<img alt={'Close'} src={XIcon.src} className="h-4 w-4" />
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,34 @@
|
||||
---
|
||||
import { getFormattedStars } from '../lib/github';
|
||||
import Icon from './AstroIcon.astro';
|
||||
import { getDiscordInfo } from '../lib/discord';
|
||||
import OpenSourceStat from './OpenSourceStat.astro';
|
||||
|
||||
const starCount = await getFormattedStars('kamranahmedse/developer-roadmap');
|
||||
const discordInfo = await getDiscordInfo();
|
||||
---
|
||||
|
||||
<div class='py-6 sm:py-16 border-b border-t text-left sm:text-center bg-white'>
|
||||
<div class='!max-w-[600px] container'>
|
||||
<p class='text-2xl sm:text-5xl font-bold'>Community</p>
|
||||
<p class='text-gray-600 text-sm sm:text-lg leading-relaxed my-2.5 sm:my-5'>
|
||||
<div class='border-b border-t bg-white py-6 text-left sm:py-16 sm:text-center'>
|
||||
<div class='container !max-w-[650px]'>
|
||||
<p class='text-2xl font-bold sm:text-5xl'>Join the Community</p>
|
||||
<p class='my-2.5 text-sm leading-relaxed text-gray-600 sm:my-5 sm:text-lg'>
|
||||
roadmap.sh is the <a
|
||||
href='https://github.com/search?o=desc&q=stars%3A%3E100000&s=stars&type=Repositories'
|
||||
target='_blank'
|
||||
class='font-medium text-gray-600 hover:text-black underline underline-offset-2'
|
||||
class='font-medium text-gray-600 underline underline-offset-2 hover:text-black'
|
||||
>6th most starred project on GitHub</a
|
||||
> and is visited by hundreds of thousands of developers every month.
|
||||
</p>
|
||||
|
||||
<div class='flex justify-start flex-col sm:flex-row sm:justify-center gap-2 sm:gap-3 mb-1.5 sm:mb-0'>
|
||||
<a
|
||||
href='https://github.com/kamranahmedse/developer-roadmap'
|
||||
target='_blank'
|
||||
class='inline-flex items-center border border-black py-1.5 px-3 rounded-lg text-sm hover:text-white hover:bg-black bg-white'
|
||||
>
|
||||
<Icon icon='star' class='mr-1 -ml-1 fill-current' />
|
||||
|
||||
<span class='lowercase'>{starCount}</span>
|
||||
<span class='ml-2 hover:block'>GitHub Stars</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://discord.gg/cJpEt5Qbwa"
|
||||
target='_blank'
|
||||
class='relative pointer inline-flex items-center border border-black py-1.5 px-3 rounded-lg text-sm hover:text-white hover:bg-black bg-white group'
|
||||
>
|
||||
<Icon icon='discord' class='h-[14px] mr-2 -ml-1 fill-current' />
|
||||
Join on Discord
|
||||
</a>
|
||||
<div
|
||||
class='mt-5 grid grid-cols-1 justify-between gap-2 divide-x-0 sm:my-11 sm:grid-cols-3 sm:gap-0 sm:divide-x'
|
||||
>
|
||||
<OpenSourceStat text='GitHub Stars' value={starCount} />
|
||||
<OpenSourceStat text='Registered Users' value={'850k'} />
|
||||
<OpenSourceStat
|
||||
text='Discord Members'
|
||||
value={discordInfo.totalFormatted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
105
src/components/OpenSourceStat.astro
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
import { ChevronUp } from 'lucide-react';
|
||||
import Icon from './AstroIcon.astro';
|
||||
export interface Props {
|
||||
value: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const { value, text } = Astro.props;
|
||||
|
||||
const isGitHubStars = text.toLowerCase() === 'github stars';
|
||||
const isRegistered = text.toLowerCase() === 'registered users';
|
||||
const isDiscordMembers = text.toLowerCase() === 'discord members';
|
||||
---
|
||||
|
||||
<div
|
||||
class='flex items-start sm:items-center justify-start flex-col sm:justify-center sm:gap-0 gap-2 sm:bg-transparent bg-gray-200 sm:rounded-none rounded-xl p-4'
|
||||
>
|
||||
{
|
||||
isGitHubStars && (
|
||||
<p class='flex items-center text-sm text-blue-500 sm:flex'>
|
||||
<span class='rounded-md bg-blue-500 px-1 text-white'>Rank 6th</span>
|
||||
out of 28M!
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
isRegistered && (
|
||||
<p class='flex items-center text-sm text-blue-500 sm:flex'>
|
||||
<span class='mr-1.5 rounded-md bg-blue-500 px-1 text-white'>+55k</span>
|
||||
every month
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
isDiscordMembers && (
|
||||
<p class='flex items-center text-sm text-blue-500 sm:flex'>
|
||||
<span class='mr-1.5 rounded-md bg-blue-500 px-1 text-white'>+1.5k</span>
|
||||
every month
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<div class="flex flex-row items-center sm:flex-col my-1 sm:my-0">
|
||||
<p
|
||||
class='relative my-0 sm:my-4 mr-1 sm:mr-0 text-base font-bold lowercase sm:w-auto sm:text-5xl'
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p class='mb-0 mt-0 text-base sm:text-xs tracking-wide text-black sm:-mt-3 sm:mb-5'>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
{
|
||||
isGitHubStars && (
|
||||
<a
|
||||
href='https://github.com/kamranahmedse/developer-roadmap'
|
||||
target='_blank'
|
||||
class='group mt-0 flex flex-col items-center rounded-lg border border-black bg-white px-3 py-2 text-sm hover:bg-black hover:text-white'
|
||||
>
|
||||
<div class='mb-0.5 flex items-center font-semibold'>
|
||||
<Icon icon='star' class='-ml-1 fill-current' />
|
||||
<span class='ml-1.5 hover:block'>Star us on GitHub</span>
|
||||
</div>
|
||||
<span class='text-xs text-gray-500 group-hover:text-gray-100'>
|
||||
Help us reach #1
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
isRegistered && (
|
||||
<a
|
||||
href='/signup'
|
||||
class='group mt-0 flex flex-col items-center rounded-lg border border-black bg-white px-3 py-2 text-sm hover:bg-black hover:text-white'
|
||||
>
|
||||
<div class='mb-0.5 flex items-center font-semibold'>
|
||||
<Icon icon='users' class='-ml-1 h-[15px] fill-current' />
|
||||
<span class='ml-1 hover:block'>Register yourself</span>
|
||||
</div>
|
||||
<span class='text-xs text-gray-500 group-hover:text-gray-100'>
|
||||
Commit to your growth
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
isDiscordMembers && (
|
||||
<a
|
||||
href='https://discord.gg/cJpEt5Qbwa'
|
||||
target='_blank'
|
||||
class='group mt-0 flex flex-col items-center rounded-lg border border-black bg-white px-3 py-2 text-sm hover:bg-black hover:text-white'
|
||||
>
|
||||
<div class='mb-0.5 flex items-center font-semibold'>
|
||||
<Icon icon='discord' class='-ml-1 h-[13px] fill-current' />
|
||||
<span class='ml-1 hover:block'>Join on Discord</span>
|
||||
</div>
|
||||
<span class='text-xs text-gray-500 group-hover:text-gray-100'>
|
||||
Join the community
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import SpinnerIcon from '../icons/spinner.svg';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Spinner } from './ReactIcons/Spinner';
|
||||
|
||||
export interface Props {
|
||||
initialMessage: string;
|
||||
@@ -30,10 +30,10 @@ export function PageProgress(props: Props) {
|
||||
{/* Tailwind based spinner for full page */}
|
||||
<div className="fixed left-0 top-0 z-50 flex h-full w-full items-center justify-center bg-white bg-opacity-75">
|
||||
<div className="flex items-center justify-center rounded-md border bg-white px-4 py-2 ">
|
||||
<img
|
||||
src={SpinnerIcon.src}
|
||||
alt="Loading"
|
||||
className="h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-4 sm:w-4"
|
||||
<Spinner
|
||||
className="h-4 w-4 sm:h-4 sm:w-4"
|
||||
outerFill="#e5e7eb"
|
||||
innerFill="#2563eb"
|
||||
/>
|
||||
<h1 className="ml-2">
|
||||
{message}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import CloseIcon from '../icons/close.svg';
|
||||
import { httpGet } from '../lib/http';
|
||||
import { sponsorHidden } from '../stores/page';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export type PageSponsorType = {
|
||||
company: string;
|
||||
@@ -46,7 +46,7 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
|
||||
{
|
||||
href: window.location.pathname,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -101,11 +101,11 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
sponsorHidden.set(true);
|
||||
}}
|
||||
>
|
||||
<img alt="Close" className="h-4 w-4" src={CloseIcon.src} />
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
<img
|
||||
src={imageUrl}
|
||||
className="block h-[150px] object-fill lg:h-[169px] lg:w-[118.18px]"
|
||||
className="block h-[150px] object-cover lg:h-[169px] lg:w-[118.18px]"
|
||||
alt="Sponsor Banner"
|
||||
/>
|
||||
<span className="flex flex-1 flex-col justify-between text-sm">
|
||||
|
||||
25
src/components/PageVisit/PageVisit.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
|
||||
type PageVisitProps = {
|
||||
resourceId?: string;
|
||||
resourceType?: ResourceType;
|
||||
};
|
||||
|
||||
export function PageVisit(props: PageVisitProps) {
|
||||
const { resourceId, resourceType } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
|
||||
...(resourceType && { resourceType, resourceId }),
|
||||
}).finally(() => {});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const { id, title, subtitle } = Astro.props;
|
||||
<div
|
||||
id={id}
|
||||
tabindex='-1'
|
||||
class='hidden bg-black/50 overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 h-full items-center justify-center popup'
|
||||
class='hidden bg-black/50 overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-[999] h-full items-center justify-center popup'
|
||||
>
|
||||
<div class='relative p-4 w-full max-w-md h-full md:h-auto'>
|
||||
<div class='relative bg-white rounded-lg shadow popup-body'>
|
||||
|
||||
24
src/components/ReactIcons/AcceptIcon.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
type AcceptIconProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AcceptIcon(props: AcceptIconProps) {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="#000"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 12.75l6 6 9-13.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
28
src/components/ReactIcons/BestPracticesIcon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
type BestPracticesIconProps = {
|
||||
className?: string;
|
||||
};
|
||||
export function BestPracticesIcon(props: BestPracticesIconProps) {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="10" x2="21" y1="6" y2="6"></line>
|
||||
<line x1="10" x2="21" y1="12" y2="12"></line>
|
||||
<line x1="10" x2="21" y1="18" y2="18"></line>
|
||||
<polyline points="3 6 4 7 6 5"></polyline>
|
||||
<polyline points="3 12 4 13 6 11"></polyline>
|
||||
<polyline points="3 18 4 19 6 17"></polyline>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
29
src/components/ReactIcons/BuildingIcon.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
type BuildingIconProps = {
|
||||
className?: string;
|
||||
};
|
||||
export function BuildingIcon(props: BuildingIconProps) {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"></path>
|
||||
<path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"></path>
|
||||
<path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"></path>
|
||||
<path d="M10 6h4"></path>
|
||||
<path d="M10 10h4"></path>
|
||||
<path d="M10 14h4"></path>
|
||||
<path d="M10 18h4"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
28
src/components/ReactIcons/ClipboardIcon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
type ClipboardIconProps = {
|
||||
className?: string;
|
||||
};
|
||||
export function ClipboardIcon(props: ClipboardIconProps) {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<path d="M12 11h4" />
|
||||
<path d="M12 16h4" />
|
||||
<path d="M8 11h.01" />
|
||||
<path d="M8 16h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
28
src/components/ReactIcons/CogIcon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
type CogIconProps = {
|
||||
className?: string;
|
||||
};
|
||||
export function CogIcon(props: CogIconProps) {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||