diff --git a/.astro/settings.json b/.astro/settings.json new file mode 100644 index 000000000..bf82959bd --- /dev/null +++ b/.astro/settings.json @@ -0,0 +1,5 @@ +{ + "devToolbar": { + "enabled": false + } +} \ No newline at end of file diff --git a/astro.config.mjs b/astro.config.mjs index 3120e8e78..88abd3b7a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -29,7 +29,7 @@ export default defineConfig({ 'mailto:', 'https://github.com/kamranahmedse', 'https://thenewstack.io', - 'https://cs.fyi', + 'https://kamranahmed.info', 'https://roadmap.sh', ]; if (whiteListedStarts.some((start) => href.startsWith(start))) { diff --git a/package.json b/package.json index 53e3b90d1..401e083bb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "generate-renderer": "sh scripts/generate-renderer.sh", "best-practice-dirs": "node scripts/best-practice-dirs.cjs", "best-practice-content": "node scripts/best-practice-content.cjs", + "generate:og": "node ./scripts/generate-og-images.mjs", "test:e2e": "playwright test" }, "dependencies": { @@ -28,16 +29,21 @@ "@astrojs/tailwind": "^5.1.0", "@fingerprintjs/fingerprintjs": "^4.2.2", "@nanostores/react": "^0.7.1", + "@resvg/resvg-js": "^2.6.0", "@types/react": "^18.2.56", "@types/react-dom": "^18.2.19", "astro": "^4.4.0", "astro-compress": "^2.2.10", "clsx": "^2.1.0", "dayjs": "^1.11.10", + "dom-to-image": "^2.6.0", "dracula-prism": "^2.1.16", + "gray-matter": "^4.0.3", + "htm": "^3.1.1", + "image-size": "^1.1.1", "jose": "^5.2.2", "js-cookie": "^3.0.5", - "lucide-react": "^0.334.0", + "lucide-react": "^0.358.0", "nanoid": "^5.0.5", "nanostores": "^0.9.5", "node-html-parser": "^6.1.12", @@ -50,15 +56,21 @@ "react-tooltip": "^5.26.3", "reactflow": "^11.10.4", "rehype-external-links": "^3.0.0", + "remark-parse": "^11.0.0", "roadmap-renderer": "^1.0.6", + "satori": "^0.10.13", + "satori-html": "^0.3.2", + "sharp": "^0.33.2", "slugify": "^1.6.6", "tailwind-merge": "^2.2.1", "tailwindcss": "^3.4.1", + "unified": "^11.0.4", "zustand": "^4.5.1" }, "devDependencies": { "@playwright/test": "^1.41.2", "@tailwindcss/typography": "^0.5.10", + "@types/dom-to-image": "^2.6.7", "@types/js-cookie": "^3.0.6", "@types/prismjs": "^1.26.3", "@types/react-calendar-heatmap": "^1.6.7", diff --git a/public/authors/peter-thaleikis.png b/public/authors/peter-thaleikis.png new file mode 100644 index 000000000..a338ab497 Binary files /dev/null and b/public/authors/peter-thaleikis.png differ diff --git a/public/best-practices/backend-performance.png b/public/best-practices/backend-performance.png new file mode 100644 index 000000000..34d812938 Binary files /dev/null and b/public/best-practices/backend-performance.png differ diff --git a/public/fonts/BalsamiqSans-Regular.ttf b/public/fonts/BalsamiqSans-Regular.ttf new file mode 100644 index 000000000..4fc9f82e9 Binary files /dev/null and b/public/fonts/BalsamiqSans-Regular.ttf differ diff --git a/public/images/graph.svg b/public/images/graph.svg new file mode 100644 index 000000000..51f61aa1c --- /dev/null +++ b/public/images/graph.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons8-wand.gif b/public/images/icons8-wand.gif new file mode 100644 index 000000000..621b405e3 Binary files /dev/null and b/public/images/icons8-wand.gif differ diff --git a/public/og-images/best-practices/api-security.png b/public/og-images/best-practices/api-security.png new file mode 100644 index 000000000..2b223533d Binary files /dev/null and b/public/og-images/best-practices/api-security.png differ diff --git a/public/og-images/best-practices/aws.png b/public/og-images/best-practices/aws.png new file mode 100644 index 000000000..0d736a471 Binary files /dev/null and b/public/og-images/best-practices/aws.png differ diff --git a/public/og-images/best-practices/backend-performance.png b/public/og-images/best-practices/backend-performance.png new file mode 100644 index 000000000..c79f8ec7e Binary files /dev/null and b/public/og-images/best-practices/backend-performance.png differ diff --git a/public/og-images/best-practices/code-review.png b/public/og-images/best-practices/code-review.png new file mode 100644 index 000000000..2fc5b01b2 Binary files /dev/null and b/public/og-images/best-practices/code-review.png differ diff --git a/public/og-images/best-practices/frontend-performance.png b/public/og-images/best-practices/frontend-performance.png new file mode 100644 index 000000000..2b5f9be3f Binary files /dev/null and b/public/og-images/best-practices/frontend-performance.png differ diff --git a/public/og-images/guides/asymptotic-notation.png b/public/og-images/guides/asymptotic-notation.png new file mode 100644 index 000000000..921123291 Binary files /dev/null and b/public/og-images/guides/asymptotic-notation.png differ diff --git a/public/og-images/guides/avoid-render-blocking-javascript-with-async-defer.png b/public/og-images/guides/avoid-render-blocking-javascript-with-async-defer.png new file mode 100644 index 000000000..1ea6f9b15 Binary files /dev/null and b/public/og-images/guides/avoid-render-blocking-javascript-with-async-defer.png differ diff --git a/public/og-images/guides/backend-developer-skills.png b/public/og-images/guides/backend-developer-skills.png new file mode 100644 index 000000000..492feba01 Binary files /dev/null and b/public/og-images/guides/backend-developer-skills.png differ diff --git a/public/og-images/guides/backend-developer-tools.png b/public/og-images/guides/backend-developer-tools.png new file mode 100644 index 000000000..0b0a578f3 Binary files /dev/null and b/public/og-images/guides/backend-developer-tools.png differ diff --git a/public/og-images/guides/backend-languages.png b/public/og-images/guides/backend-languages.png new file mode 100644 index 000000000..c3f383a65 Binary files /dev/null and b/public/og-images/guides/backend-languages.png differ diff --git a/public/og-images/guides/basic-authentication.png b/public/og-images/guides/basic-authentication.png new file mode 100644 index 000000000..6ba219322 Binary files /dev/null and b/public/og-images/guides/basic-authentication.png differ diff --git a/public/og-images/guides/basics-of-authentication.png b/public/og-images/guides/basics-of-authentication.png new file mode 100644 index 000000000..dd9bdcebf Binary files /dev/null and b/public/og-images/guides/basics-of-authentication.png differ diff --git a/public/og-images/guides/big-o-notation.png b/public/og-images/guides/big-o-notation.png new file mode 100644 index 000000000..c55d03c30 Binary files /dev/null and b/public/og-images/guides/big-o-notation.png differ diff --git a/public/og-images/guides/character-encodings.png b/public/og-images/guides/character-encodings.png new file mode 100644 index 000000000..368d80239 Binary files /dev/null and b/public/og-images/guides/character-encodings.png differ diff --git a/public/og-images/guides/ci-cd.png b/public/og-images/guides/ci-cd.png new file mode 100644 index 000000000..32163c495 Binary files /dev/null and b/public/og-images/guides/ci-cd.png differ diff --git a/public/og-images/guides/consistency-patterns-in-distributed-systems.png b/public/og-images/guides/consistency-patterns-in-distributed-systems.png new file mode 100644 index 000000000..ca233251e Binary files /dev/null and b/public/og-images/guides/consistency-patterns-in-distributed-systems.png differ diff --git a/public/og-images/guides/design-patterns-for-humans.png b/public/og-images/guides/design-patterns-for-humans.png new file mode 100644 index 000000000..54ef033cd Binary files /dev/null and b/public/og-images/guides/design-patterns-for-humans.png differ diff --git a/public/og-images/guides/dhcp-in-one-picture.png b/public/og-images/guides/dhcp-in-one-picture.png new file mode 100644 index 000000000..acf29d7a0 Binary files /dev/null and b/public/og-images/guides/dhcp-in-one-picture.png differ diff --git a/public/og-images/guides/dns-in-one-picture.png b/public/og-images/guides/dns-in-one-picture.png new file mode 100644 index 000000000..2059486f4 Binary files /dev/null and b/public/og-images/guides/dns-in-one-picture.png differ diff --git a/public/og-images/guides/free-resources-to-learn-llms.png b/public/og-images/guides/free-resources-to-learn-llms.png new file mode 100644 index 000000000..78c6cebfe Binary files /dev/null and b/public/og-images/guides/free-resources-to-learn-llms.png differ diff --git a/public/og-images/guides/history-of-javascript.png b/public/og-images/guides/history-of-javascript.png new file mode 100644 index 000000000..77d462465 Binary files /dev/null and b/public/og-images/guides/history-of-javascript.png differ diff --git a/public/og-images/guides/how-to-setup-a-jump-server.png b/public/og-images/guides/how-to-setup-a-jump-server.png new file mode 100644 index 000000000..b5f0dc813 Binary files /dev/null and b/public/og-images/guides/how-to-setup-a-jump-server.png differ diff --git a/public/og-images/guides/http-basic-authentication.png b/public/og-images/guides/http-basic-authentication.png new file mode 100644 index 000000000..c9046bd75 Binary files /dev/null and b/public/og-images/guides/http-basic-authentication.png differ diff --git a/public/og-images/guides/http-caching.png b/public/og-images/guides/http-caching.png new file mode 100644 index 000000000..6ef24a29d Binary files /dev/null and b/public/og-images/guides/http-caching.png differ diff --git a/public/og-images/guides/introduction-to-llms.png b/public/og-images/guides/introduction-to-llms.png new file mode 100644 index 000000000..36f51062d Binary files /dev/null and b/public/og-images/guides/introduction-to-llms.png differ diff --git a/public/og-images/guides/journey-to-http2.png b/public/og-images/guides/journey-to-http2.png new file mode 100644 index 000000000..7e40ed967 Binary files /dev/null and b/public/og-images/guides/journey-to-http2.png differ diff --git a/public/og-images/guides/jwt-authentication.png b/public/og-images/guides/jwt-authentication.png new file mode 100644 index 000000000..25253dcc8 Binary files /dev/null and b/public/og-images/guides/jwt-authentication.png differ diff --git a/public/og-images/guides/levels-of-seniority.png b/public/og-images/guides/levels-of-seniority.png new file mode 100644 index 000000000..ea9de6478 Binary files /dev/null and b/public/og-images/guides/levels-of-seniority.png differ diff --git a/public/og-images/guides/oauth.png b/public/og-images/guides/oauth.png new file mode 100644 index 000000000..858fa198d Binary files /dev/null and b/public/og-images/guides/oauth.png differ diff --git a/public/og-images/guides/proxy-servers.png b/public/og-images/guides/proxy-servers.png new file mode 100644 index 000000000..4575d08e0 Binary files /dev/null and b/public/og-images/guides/proxy-servers.png differ diff --git a/public/og-images/guides/random-numbers.png b/public/og-images/guides/random-numbers.png new file mode 100644 index 000000000..c34710534 Binary files /dev/null and b/public/og-images/guides/random-numbers.png differ diff --git a/public/og-images/guides/scaling-databases.png b/public/og-images/guides/scaling-databases.png new file mode 100644 index 000000000..0e251e355 Binary files /dev/null and b/public/og-images/guides/scaling-databases.png differ diff --git a/public/og-images/guides/session-authentication.png b/public/og-images/guides/session-authentication.png new file mode 100644 index 000000000..5f20cf034 Binary files /dev/null and b/public/og-images/guides/session-authentication.png differ diff --git a/public/og-images/guides/session-based-authentication.png b/public/og-images/guides/session-based-authentication.png new file mode 100644 index 000000000..f27349ea9 Binary files /dev/null and b/public/og-images/guides/session-based-authentication.png differ diff --git a/public/og-images/guides/setup-and-auto-renew-ssl-certificates.png b/public/og-images/guides/setup-and-auto-renew-ssl-certificates.png new file mode 100644 index 000000000..764565dee Binary files /dev/null and b/public/og-images/guides/setup-and-auto-renew-ssl-certificates.png differ diff --git a/public/og-images/guides/single-command-database-setup.png b/public/og-images/guides/single-command-database-setup.png new file mode 100644 index 000000000..f59c1461c Binary files /dev/null and b/public/og-images/guides/single-command-database-setup.png differ diff --git a/public/og-images/guides/ssl-tls-https-ssh.png b/public/og-images/guides/ssl-tls-https-ssh.png new file mode 100644 index 000000000..23c643a98 Binary files /dev/null and b/public/og-images/guides/ssl-tls-https-ssh.png differ diff --git a/public/og-images/guides/sso.png b/public/og-images/guides/sso.png new file mode 100644 index 000000000..964611c47 Binary files /dev/null and b/public/og-images/guides/sso.png differ diff --git a/public/og-images/guides/token-authentication.png b/public/og-images/guides/token-authentication.png new file mode 100644 index 000000000..df474f238 Binary files /dev/null and b/public/og-images/guides/token-authentication.png differ diff --git a/public/og-images/guides/torrent-client.png b/public/og-images/guides/torrent-client.png new file mode 100644 index 000000000..5402dfcf0 Binary files /dev/null and b/public/og-images/guides/torrent-client.png differ diff --git a/public/og-images/guides/unfamiliar-codebase.png b/public/og-images/guides/unfamiliar-codebase.png new file mode 100644 index 000000000..9b30849a6 Binary files /dev/null and b/public/og-images/guides/unfamiliar-codebase.png differ diff --git a/public/og-images/guides/what-are-web-vitals.png b/public/og-images/guides/what-are-web-vitals.png new file mode 100644 index 000000000..ffaa0699c Binary files /dev/null and b/public/og-images/guides/what-are-web-vitals.png differ diff --git a/public/og-images/guides/what-is-internet.png b/public/og-images/guides/what-is-internet.png new file mode 100644 index 000000000..ebc49266d Binary files /dev/null and b/public/og-images/guides/what-is-internet.png differ diff --git a/public/og-images/guides/what-is-sli-slo-sla.png b/public/og-images/guides/what-is-sli-slo-sla.png new file mode 100644 index 000000000..1cd1cbd01 Binary files /dev/null and b/public/og-images/guides/what-is-sli-slo-sla.png differ diff --git a/public/og-images/guides/why-build-it-and-they-will-come-wont-work-anymore.png b/public/og-images/guides/why-build-it-and-they-will-come-wont-work-anymore.png new file mode 100644 index 000000000..07d31f813 Binary files /dev/null and b/public/og-images/guides/why-build-it-and-they-will-come-wont-work-anymore.png differ diff --git a/public/og-images/roadmaps/ai-data-scientist.png b/public/og-images/roadmaps/ai-data-scientist.png new file mode 100644 index 000000000..8f92bd7e4 Binary files /dev/null and b/public/og-images/roadmaps/ai-data-scientist.png differ diff --git a/public/og-images/roadmaps/android.png b/public/og-images/roadmaps/android.png new file mode 100644 index 000000000..22185d37b Binary files /dev/null and b/public/og-images/roadmaps/android.png differ diff --git a/public/og-images/roadmaps/angular.png b/public/og-images/roadmaps/angular.png new file mode 100644 index 000000000..93ed3436f Binary files /dev/null and b/public/og-images/roadmaps/angular.png differ diff --git a/public/og-images/roadmaps/aspnet-core.png b/public/og-images/roadmaps/aspnet-core.png new file mode 100644 index 000000000..e76626536 Binary files /dev/null and b/public/og-images/roadmaps/aspnet-core.png differ diff --git a/public/og-images/roadmaps/aws.png b/public/og-images/roadmaps/aws.png new file mode 100644 index 000000000..d483cc8a6 Binary files /dev/null and b/public/og-images/roadmaps/aws.png differ diff --git a/public/og-images/roadmaps/backend.png b/public/og-images/roadmaps/backend.png new file mode 100644 index 000000000..adb0f1345 Binary files /dev/null and b/public/og-images/roadmaps/backend.png differ diff --git a/public/og-images/roadmaps/blockchain.png b/public/og-images/roadmaps/blockchain.png new file mode 100644 index 000000000..d99d16985 Binary files /dev/null and b/public/og-images/roadmaps/blockchain.png differ diff --git a/public/og-images/roadmaps/code-review.png b/public/og-images/roadmaps/code-review.png new file mode 100644 index 000000000..055390d47 Binary files /dev/null and b/public/og-images/roadmaps/code-review.png differ diff --git a/public/og-images/roadmaps/computer-science.png b/public/og-images/roadmaps/computer-science.png new file mode 100644 index 000000000..50bb98a5c Binary files /dev/null and b/public/og-images/roadmaps/computer-science.png differ diff --git a/public/og-images/roadmaps/cpp.png b/public/og-images/roadmaps/cpp.png new file mode 100644 index 000000000..a0622b049 Binary files /dev/null and b/public/og-images/roadmaps/cpp.png differ diff --git a/public/og-images/roadmaps/cyber-security.png b/public/og-images/roadmaps/cyber-security.png new file mode 100644 index 000000000..4417371c7 Binary files /dev/null and b/public/og-images/roadmaps/cyber-security.png differ diff --git a/public/og-images/roadmaps/data-analyst.png b/public/og-images/roadmaps/data-analyst.png new file mode 100644 index 000000000..825571d57 Binary files /dev/null and b/public/og-images/roadmaps/data-analyst.png differ diff --git a/public/og-images/roadmaps/datastructures-and-algorithms.png b/public/og-images/roadmaps/datastructures-and-algorithms.png new file mode 100644 index 000000000..8a349c525 Binary files /dev/null and b/public/og-images/roadmaps/datastructures-and-algorithms.png differ diff --git a/public/og-images/roadmaps/design-system.png b/public/og-images/roadmaps/design-system.png new file mode 100644 index 000000000..db30cbade Binary files /dev/null and b/public/og-images/roadmaps/design-system.png differ diff --git a/public/og-images/roadmaps/devops.png b/public/og-images/roadmaps/devops.png new file mode 100644 index 000000000..bacedd8ae Binary files /dev/null and b/public/og-images/roadmaps/devops.png differ diff --git a/public/og-images/roadmaps/docker.png b/public/og-images/roadmaps/docker.png new file mode 100644 index 000000000..6ddc7c738 Binary files /dev/null and b/public/og-images/roadmaps/docker.png differ diff --git a/public/og-images/roadmaps/flutter.png b/public/og-images/roadmaps/flutter.png new file mode 100644 index 000000000..cb525de86 Binary files /dev/null and b/public/og-images/roadmaps/flutter.png differ diff --git a/public/og-images/roadmaps/frontend.png b/public/og-images/roadmaps/frontend.png new file mode 100644 index 000000000..18620b7da Binary files /dev/null and b/public/og-images/roadmaps/frontend.png differ diff --git a/public/og-images/roadmaps/full-stack.png b/public/og-images/roadmaps/full-stack.png new file mode 100644 index 000000000..c0110efb2 Binary files /dev/null and b/public/og-images/roadmaps/full-stack.png differ diff --git a/public/og-images/roadmaps/game-developer.png b/public/og-images/roadmaps/game-developer.png new file mode 100644 index 000000000..9023ca835 Binary files /dev/null and b/public/og-images/roadmaps/game-developer.png differ diff --git a/public/og-images/roadmaps/golang.png b/public/og-images/roadmaps/golang.png new file mode 100644 index 000000000..cc41702b0 Binary files /dev/null and b/public/og-images/roadmaps/golang.png differ diff --git a/public/og-images/roadmaps/graphql.png b/public/og-images/roadmaps/graphql.png new file mode 100644 index 000000000..df845bcad Binary files /dev/null and b/public/og-images/roadmaps/graphql.png differ diff --git a/public/og-images/roadmaps/java.png b/public/og-images/roadmaps/java.png new file mode 100644 index 000000000..21c0f8305 Binary files /dev/null and b/public/og-images/roadmaps/java.png differ diff --git a/public/og-images/roadmaps/javascript.png b/public/og-images/roadmaps/javascript.png new file mode 100644 index 000000000..6f51c790e Binary files /dev/null and b/public/og-images/roadmaps/javascript.png differ diff --git a/public/og-images/roadmaps/kubernetes.png b/public/og-images/roadmaps/kubernetes.png new file mode 100644 index 000000000..6dc011149 Binary files /dev/null and b/public/og-images/roadmaps/kubernetes.png differ diff --git a/public/og-images/roadmaps/mlops.png b/public/og-images/roadmaps/mlops.png new file mode 100644 index 000000000..50f78c154 Binary files /dev/null and b/public/og-images/roadmaps/mlops.png differ diff --git a/public/og-images/roadmaps/mongodb.png b/public/og-images/roadmaps/mongodb.png new file mode 100644 index 000000000..c4b152ddd Binary files /dev/null and b/public/og-images/roadmaps/mongodb.png differ diff --git a/public/og-images/roadmaps/nodejs.png b/public/og-images/roadmaps/nodejs.png new file mode 100644 index 000000000..bd05279b5 Binary files /dev/null and b/public/og-images/roadmaps/nodejs.png differ diff --git a/public/og-images/roadmaps/postgresql-dba.png b/public/og-images/roadmaps/postgresql-dba.png new file mode 100644 index 000000000..7fd7cfa86 Binary files /dev/null and b/public/og-images/roadmaps/postgresql-dba.png differ diff --git a/public/og-images/roadmaps/prompt-engineering.png b/public/og-images/roadmaps/prompt-engineering.png new file mode 100644 index 000000000..c308dfd40 Binary files /dev/null and b/public/og-images/roadmaps/prompt-engineering.png differ diff --git a/public/og-images/roadmaps/python.png b/public/og-images/roadmaps/python.png new file mode 100644 index 000000000..35687f082 Binary files /dev/null and b/public/og-images/roadmaps/python.png differ diff --git a/public/og-images/roadmaps/qa.png b/public/og-images/roadmaps/qa.png new file mode 100644 index 000000000..283ac7a8f Binary files /dev/null and b/public/og-images/roadmaps/qa.png differ diff --git a/public/og-images/roadmaps/react-native.png b/public/og-images/roadmaps/react-native.png new file mode 100644 index 000000000..5083e915f Binary files /dev/null and b/public/og-images/roadmaps/react-native.png differ diff --git a/public/og-images/roadmaps/react.png b/public/og-images/roadmaps/react.png new file mode 100644 index 000000000..f3f6792ff Binary files /dev/null and b/public/og-images/roadmaps/react.png differ diff --git a/public/og-images/roadmaps/rust.png b/public/og-images/roadmaps/rust.png new file mode 100644 index 000000000..9cb0233aa Binary files /dev/null and b/public/og-images/roadmaps/rust.png differ diff --git a/public/og-images/roadmaps/server-side-game-developer.png b/public/og-images/roadmaps/server-side-game-developer.png new file mode 100644 index 000000000..89759d0a0 Binary files /dev/null and b/public/og-images/roadmaps/server-side-game-developer.png differ diff --git a/public/og-images/roadmaps/software-architect.png b/public/og-images/roadmaps/software-architect.png new file mode 100644 index 000000000..41adfc831 Binary files /dev/null and b/public/og-images/roadmaps/software-architect.png differ diff --git a/public/og-images/roadmaps/software-design-architecture.png b/public/og-images/roadmaps/software-design-architecture.png new file mode 100644 index 000000000..3b9d260ef Binary files /dev/null and b/public/og-images/roadmaps/software-design-architecture.png differ diff --git a/public/og-images/roadmaps/spring-boot.png b/public/og-images/roadmaps/spring-boot.png new file mode 100644 index 000000000..ca550d6b7 Binary files /dev/null and b/public/og-images/roadmaps/spring-boot.png differ diff --git a/public/og-images/roadmaps/sql.png b/public/og-images/roadmaps/sql.png new file mode 100644 index 000000000..7a30c975d Binary files /dev/null and b/public/og-images/roadmaps/sql.png differ diff --git a/public/og-images/roadmaps/system-design.png b/public/og-images/roadmaps/system-design.png new file mode 100644 index 000000000..1dfd75e7d Binary files /dev/null and b/public/og-images/roadmaps/system-design.png differ diff --git a/public/og-images/roadmaps/technical-writer.png b/public/og-images/roadmaps/technical-writer.png new file mode 100644 index 000000000..a0250aafb Binary files /dev/null and b/public/og-images/roadmaps/technical-writer.png differ diff --git a/public/og-images/roadmaps/typescript.png b/public/og-images/roadmaps/typescript.png new file mode 100644 index 000000000..df2128886 Binary files /dev/null and b/public/og-images/roadmaps/typescript.png differ diff --git a/public/og-images/roadmaps/ux-design.png b/public/og-images/roadmaps/ux-design.png new file mode 100644 index 000000000..590445acf Binary files /dev/null and b/public/og-images/roadmaps/ux-design.png differ diff --git a/public/og-images/roadmaps/vue.png b/public/og-images/roadmaps/vue.png new file mode 100644 index 000000000..05c7e0e16 Binary files /dev/null and b/public/og-images/roadmaps/vue.png differ diff --git a/public/pdfs/best-practices/backend-performance.pdf b/public/pdfs/best-practices/backend-performance.pdf new file mode 100644 index 000000000..8ba3aa62f Binary files /dev/null and b/public/pdfs/best-practices/backend-performance.pdf differ diff --git a/public/pdfs/roadmaps/data-analyst.pdf b/public/pdfs/roadmaps/data-analyst.pdf new file mode 100644 index 000000000..52f180b49 Binary files /dev/null and b/public/pdfs/roadmaps/data-analyst.pdf differ diff --git a/public/roadmaps/data-analyst.png b/public/roadmaps/data-analyst.png new file mode 100644 index 000000000..f228e33ed Binary files /dev/null and b/public/roadmaps/data-analyst.png differ diff --git a/public/roadmaps/frontend.png b/public/roadmaps/frontend.png index 5b75387e1..42ef8d745 100644 Binary files a/public/roadmaps/frontend.png and b/public/roadmaps/frontend.png differ diff --git a/readme.md b/readme.md index ca2d5019a..bbfef72dc 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,8 @@ 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. +> Have a look at the [get started](https://roadmap.sh/get-started) page that might help you pick up a path. + - [Frontend Roadmap](https://roadmap.sh/frontend) / [Frontend Beginner Roadmap](https://roadmap.sh/frontend?r=frontend-beginner) - [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) @@ -37,6 +39,7 @@ Here is the list of available roadmaps with more being actively worked upon. - [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) +- [Data Analyst Roadmap](https://roadmap.sh/data-analyst) - [MLOps Roadmap](https://roadmap.sh/mlops) - [QA Roadmap](https://roadmap.sh/qa) - [Python Roadmap](https://roadmap.sh/python) @@ -74,14 +77,16 @@ Here is the list of available roadmaps with more being actively worked upon. There are also interactive best practices: -- [Code Review Best Practices](https://roadmap.sh/best-practices/code-review) +- [Backend Performance Best Practices](https://roadmap.sh/best-practices/backend-performance) - [Frontend Performance Best Practices](https://roadmap.sh/best-practices/frontend-performance) +- [Code Review Best Practices](https://roadmap.sh/best-practices/code-review) - [API Security Best Practices](https://roadmap.sh/best-practices/api-security) - [AWS Best Practices](https://roadmap.sh/best-practices/aws) ..and questions to help you test, rate and improve your knowledge - [JavaScript Questions](https://roadmap.sh/questions/javascript) +- [Node.js Questions](https://roadmap.sh/questions/nodejs) - [React Questions](https://roadmap.sh/questions/react) ![](https://i.imgur.com/waxVImv.png) diff --git a/scripts/best-practice-content.cjs b/scripts/best-practice-content.cjs index 439f4a8cc..743674170 100644 --- a/scripts/best-practice-content.cjs +++ b/scripts/best-practice-content.cjs @@ -4,11 +4,7 @@ const path = require('path'); const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY; const ALL_BEST_PRACTICES_DIR = path.join( __dirname, - '../src/data/best-practices' -); -const BEST_PRACTICE_JSON_DIR = path.join( - __dirname, - '../public/jsons/best-practices' + '../src/data/best-practices', ); const bestPracticeId = process.argv[2]; @@ -29,15 +25,14 @@ if (!allowedBestPracticeIds.includes(bestPracticeId)) { const BEST_PRACTICE_CONTENT_DIR = path.join( ALL_BEST_PRACTICES_DIR, bestPracticeId, - 'content' + 'content', ); -const { Configuration, OpenAIApi } = require('openai'); -const configuration = new Configuration({ +const OpenAI = require('openai'); + +const openai = new OpenAI({ apiKey: OPEN_AI_API_KEY, }); -const openai = new OpenAIApi(configuration); - function getFilesInFolder(folderPath, fileList = {}) { const files = fs.readdirSync(folderPath); @@ -62,13 +57,19 @@ function getFilesInFolder(folderPath, fileList = {}) { } function writeTopicContent(topicTitle) { - let prompt = `I am reading a guide that has best practices about "${bestPracticeTitle}". I want to know more about "${topicTitle}". Write me a brief introductory paragraph about this and some tips on how I make sure of this? Behave as if you are the author of the guide.`; + let prompt = `I will give you a topic and you need to write a brief paragraph with examples (if possible) about why it is important for the "${bestPracticeTitle}". Just reply to the question without adding any other information about the prompt and use simple language. Also do not start your sentences with "XYZ is important because..". Your format should be as follows: + +# (Put a heading for the topic) + +(Write a brief paragraph about why it is important for the "${bestPracticeTitle}) + +First topic is: ${topicTitle}`; console.log(`Generating '${topicTitle}'...`); return new Promise((resolve, reject) => { - openai - .createChatCompletion({ + openai.chat.completions + .create({ model: 'gpt-4', messages: [ { @@ -78,7 +79,7 @@ function writeTopicContent(topicTitle) { ], }) .then((response) => { - const article = response.data.choices[0].message.content; + const article = response.choices[0].message.content; resolve(article); }) @@ -90,9 +91,12 @@ function writeTopicContent(topicTitle) { async function writeFileForGroup(group, topicUrlToPathMapping) { const topicId = group?.properties?.controlName; - const topicTitle = group?.children?.controls?.control?.find( - (control) => control?.typeID === 'Label' - )?.properties?.text; + const topicTitle = group?.children?.controls?.control + ?.filter((control) => control?.typeID === 'Label') + .map((control) => control?.properties?.text) + .join(' ') + .toLowerCase(); + const currTopicUrl = `/${topicId}`; if (currTopicUrl.startsWith('/check:')) { return; @@ -102,7 +106,6 @@ async function writeFileForGroup(group, topicUrlToPathMapping) { if (!contentFilePath) { console.log(`Missing file for: ${currTopicUrl}`); - process.exit(0); return; } @@ -123,8 +126,13 @@ async function writeFileForGroup(group, topicUrlToPathMapping) { return; } + if (!topicTitle) { + console.log(`Skipping ${topicId}. No title.`); + return; + } + const topicContent = await writeTopicContent(topicTitle); - newFileContent += `\n\n${topicContent}`; + newFileContent = `${topicContent}`; console.log(`Writing ${topicId}..`); fs.writeFileSync(contentFilePath, newFileContent, 'utf8'); @@ -138,14 +146,14 @@ async function writeFileForGroup(group, topicUrlToPathMapping) { async function run() { const topicUrlToPathMapping = getFilesInFolder(BEST_PRACTICE_CONTENT_DIR); - const bestPracticeJson = require(path.join( - BEST_PRACTICE_JSON_DIR, - `${bestPracticeId}.json` - )); + const bestPracticeJson = require( + path.join(ALL_BEST_PRACTICES_DIR, `${bestPracticeId}/${bestPracticeId}`), + ); + const groups = bestPracticeJson?.mockup?.controls?.control?.filter( (control) => control.typeID === '__group__' && - !control.properties?.controlName?.startsWith('ext_link') + !control.properties?.controlName?.startsWith('ext_link'), ); if (!OPEN_AI_API_KEY) { diff --git a/scripts/best-practice-dirs.cjs b/scripts/best-practice-dirs.cjs index d5f0bf8a7..a55efee22 100644 --- a/scripts/best-practice-dirs.cjs +++ b/scripts/best-practice-dirs.cjs @@ -5,7 +5,7 @@ const CONTENT_DIR = path.join(__dirname, '../content'); // Directory containing the best-practices const BEST_PRACTICE_CONTENT_DIR = path.join( __dirname, - '../src/data/best-practices' + '../src/data/best-practices', ); const bestPracticeId = process.argv[2]; @@ -33,18 +33,18 @@ if (!bestPracticeDirName) { const bestPracticeDirPath = path.join( BEST_PRACTICE_CONTENT_DIR, - bestPracticeDirName + bestPracticeDirName, ); const bestPracticeContentDirPath = path.join( BEST_PRACTICE_CONTENT_DIR, bestPracticeDirName, - 'content' + 'content', ); // If best practice content already exists do not proceed as it would override the files if (fs.existsSync(bestPracticeContentDirPath)) { console.error( - `Best Practice content already exists @ ${bestPracticeContentDirPath}` + `Best Practice content already exists @ ${bestPracticeContentDirPath}`, ); process.exit(1); } @@ -88,10 +88,12 @@ function prepareDirTree(control, dirTree) { return { dirTree }; } -const bestPractice = require(path.join( - __dirname, - `../public/jsons/best-practices/${bestPracticeId}` -)); +const bestPractice = require( + path.join( + __dirname, + `../src/data/best-practices/${bestPracticeId}/${bestPracticeId}`, + ), +); const controls = bestPractice.mockup.controls.control; // Prepare the dir tree that we will be creating diff --git a/scripts/generate-og-images.mjs b/scripts/generate-og-images.mjs new file mode 100644 index 000000000..1c1357f9a --- /dev/null +++ b/scripts/generate-og-images.mjs @@ -0,0 +1,554 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import matter from 'gray-matter'; +import { html } from 'satori-html'; +import satori from 'satori'; +import sharp from 'sharp'; +import imageSize from 'image-size'; +import { Resvg } from '@resvg/resvg-js'; + +const ALL_ROADMAP_DIR = path.join(process.cwd(), '/src/data/roadmaps'); +const ALL_BEST_PRACTICE_DIR = path.join( + process.cwd(), + '/src/data/best-practices', +); +const ALL_GUIDE_DIR = path.join(process.cwd(), '/src/data/guides'); +const ALl_AUTHOR_DIR = path.join(process.cwd(), '/src/data/authors'); +const ALL_ROADMAP_IMAGE_DIR = path.join(process.cwd(), '/public/roadmaps'); +const ALL_BEST_PRACTICE_IMAGE_DIR = path.join( + process.cwd(), + '/public/best-practices', +); +const ALL_AUTHOR_IMAGE_DIR = path.join(process.cwd(), '/public'); + +const alreadyGeneratedImages = await fs.readdir( + path.join(process.cwd(), '/public/og-images'), + { + recursive: true, + }, +); + +async function getAllRoadmaps() { + const allRoadmapDirNames = await fs.readdir(ALL_ROADMAP_DIR); + + const allRoadmapFrontmatter = await Promise.all( + allRoadmapDirNames.map(async (roadmapDirName) => { + const roadmapDirPath = path.join( + ALL_ROADMAP_DIR, + roadmapDirName, + `${roadmapDirName}.md`, + ); + + const markdown = await fs.readFile(roadmapDirPath, 'utf8'); + const { data } = matter(markdown); + + return { + id: roadmapDirName, + title: data?.briefTitle, + description: data?.briefDescription, + }; + }), + ); + + return allRoadmapFrontmatter; +} + +async function getAllBestPractices() { + const allBestPracticeDirNames = await fs.readdir(ALL_BEST_PRACTICE_DIR); + + const allBestPracticeFrontmatter = await Promise.all( + allBestPracticeDirNames.map(async (bestPracticeDirName) => { + const bestPracticeDirPath = path.join( + ALL_BEST_PRACTICE_DIR, + bestPracticeDirName, + `${bestPracticeDirName}.md`, + ); + + const markdown = await fs.readFile(bestPracticeDirPath, 'utf8'); + const { data } = matter(markdown); + + return { + id: bestPracticeDirName, + title: data?.briefTitle, + description: data?.briefDescription, + }; + }), + ); + + return allBestPracticeFrontmatter; +} + +async function getAllGuides() { + const allGuideDirNames = await fs.readdir(ALL_GUIDE_DIR); + + const allGuideFrontmatter = await Promise.all( + allGuideDirNames.map(async (guideDirName) => { + const guideDirPath = path.join(ALL_GUIDE_DIR, guideDirName); + + const markdown = await fs.readFile(guideDirPath, 'utf8'); + const { data } = matter(markdown); + + return { + id: guideDirName?.replace('.md', ''), + title: data?.title, + description: data?.description, + authorId: data?.authorId, + }; + }), + ); + + return allGuideFrontmatter; +} + +async function getAllAuthors() { + const allAuthorDirNames = await fs.readdir(ALl_AUTHOR_DIR); + + const allAuthorFrontmatter = await Promise.all( + allAuthorDirNames.map(async (authorDirName) => { + const authorDirPath = path.join(ALl_AUTHOR_DIR, authorDirName); + + const markdown = await fs.readFile(authorDirPath, 'utf8'); + const { data } = matter(markdown); + + return { + id: authorDirName?.replace('.md', ''), + name: data?.name, + imageUrl: data?.imageUrl, + }; + }), + ); + + return allAuthorFrontmatter; +} + +async function getAllRoadmapImageIds() { + const allRoadmapImageDirNames = await fs.readdir(ALL_ROADMAP_IMAGE_DIR); + + return allRoadmapImageDirNames?.reduce((acc, image) => { + acc[image.replace(/(\.[^.]*)$/, '')] = image; + return acc; + }, {}); +} + +async function getAllBestPracticeImageIds() { + const allBestPracticeImageDirNames = await fs.readdir( + ALL_BEST_PRACTICE_IMAGE_DIR, + ); + + return allBestPracticeImageDirNames?.reduce((acc, image) => { + acc[image.replace(/(\.[^.]*)$/, '')] = image; + return acc; + }, {}); +} + +async function generateResourceOpenGraph() { + const allRoadmaps = (await getAllRoadmaps()).filter( + (roadmap) => !alreadyGeneratedImages.includes(`roadmaps/${roadmap.id}.png`), + ); + const allBestPractices = (await getAllBestPractices()).filter( + (bestPractice) => + !alreadyGeneratedImages.includes(`best-practices/${bestPractice.id}.png`), + ); + const allRoadmapImageIds = await getAllRoadmapImageIds(); + const allBestPracticeImageIds = await getAllBestPracticeImageIds(); + + const resources = []; + allRoadmaps.forEach((roadmap) => { + const hasImage = allRoadmapImageIds?.[roadmap.id]; + resources.push({ + type: 'roadmaps', + id: roadmap.id, + title: roadmap.title, + description: roadmap.description, + image: hasImage + ? path.join(ALL_ROADMAP_IMAGE_DIR, allRoadmapImageIds[roadmap.id]) + : null, + }); + }); + + allBestPractices.forEach((bestPractice) => { + const hasImage = allBestPracticeImageIds?.[bestPractice.id]; + resources.push({ + type: 'best-practices', + id: bestPractice.id, + title: bestPractice.title, + description: bestPractice.description, + image: hasImage + ? path.join( + ALL_BEST_PRACTICE_IMAGE_DIR, + allBestPracticeImageIds[bestPractice.id], + ) + : null, + }); + }); + + for (const resource of resources) { + if (!resource.image) { + let template = getRoadmapDefaultTemplate(resource); + if ( + hasSpecialCharacters(resource.title) || + hasSpecialCharacters(resource.description) + ) { + // For some reason special characters are not being rendered properly + // https://github.com/natemoo-re/satori-html/issues/20 + // So we need to unescape the html + template = JSON.parse(unescapeHtml(JSON.stringify(template))); + } + await generateOpenGraph( + template, + resource.type, + resource.id + '.png', + 'resvg', + ); + } else { + const image = await fs.readFile(resource.image); + const dimensions = imageSize(image); + + const widthRatio = 1200 / dimensions.width; + let width = dimensions.width * widthRatio * 0.85; + let height = dimensions.height * widthRatio * 0.85; + + let template = getRoadmapImageTemplate({ + ...resource, + image: `data:image/${dimensions.type};base64,${image.toString('base64')}`, + width, + height, + }); + + if ( + hasSpecialCharacters(resource.title) || + hasSpecialCharacters(resource.description) + ) { + // For some reason special characters are not being rendered properly + // https://github.com/natemoo-re/satori-html/issues/20 + // So we need to unescape the html + template = JSON.parse(unescapeHtml(JSON.stringify(template))); + } + + await generateOpenGraph(template, resource.type, resource.id + '.png'); + } + } +} + +async function generateGuideOpenGraph() { + const allGuides = (await getAllGuides()).filter( + (guide) => !alreadyGeneratedImages.includes(`guides/${guide.id}.png`), + ); + const allAuthors = await getAllAuthors(); + + for (const guide of allGuides) { + const author = allAuthors.find((author) => author.id === guide.authorId); + const image = + author?.imageUrl || 'https://roadmap.sh/images/default-avatar.png'; + const isExternalImage = image?.startsWith('http'); + let authorImageExtention = ''; + let authorAvatar; + if (!isExternalImage) { + authorAvatar = await fs.readFile(path.join(ALL_AUTHOR_IMAGE_DIR, image)); + authorImageExtention = image?.split('.')[1]; + } + + const template = getGuideTemplate({ + ...guide, + authorName: author.name, + authorAvatar: isExternalImage + ? image + : `data:image/${authorImageExtention};base64,${authorAvatar.toString('base64')}`, + }); + if ( + hasSpecialCharacters(guide.title) || + hasSpecialCharacters(guide.description) + ) { + // For some reason special characters are not being rendered properly + // https://github.com/natemoo-re/satori-html/issues/20 + // So we need to unescape the html + template = JSON.parse(unescapeHtml(JSON.stringify(template))); + } + await generateOpenGraph(template, 'guides', guide.id + '.png'); + } +} + +async function generateOpenGraph( + htmlString, + type, + fileName, + renderer = 'sharp', +) { + console.log('Started 🚀', `${type}/${fileName}`); + const svg = await satori(htmlString, { + width: 1200, + height: 630, + fonts: [ + { + name: 'balsamiq', + data: await fs.readFile( + path.join(process.cwd(), '/public/fonts/BalsamiqSans-Regular.ttf'), + ), + weight: 400, + style: 'normal', + }, + ], + }); + + await fs.mkdir(path.join(process.cwd(), '/public/og-images/' + type), { + recursive: true, + }); + // It will be used to generate the default image + // for some reasone sharp is not working with this + // FIXME: Investigate why sharp is not working with this + if (renderer === 'resvg') { + const resvg = new Resvg(svg, { + fitTo: { + mode: 'width', + value: 2500, + }, + }); + const pngData = resvg.render(); + const pngBuffer = pngData.asPng(); + await fs.writeFile( + path.join(process.cwd(), '/public/og-images/' + `${type}/${fileName}`), + pngBuffer, + ); + } else { + await sharp(Buffer.from(svg), { density: 150 }) + .png() + .toFile( + path.join(process.cwd(), '/public/og-images/' + `${type}/${fileName}`), + ); + } + + console.log('Completed ✅', `${type}/${fileName}`); +} + +await generateResourceOpenGraph(); +await generateGuideOpenGraph(); + +function getRoadmapDefaultTemplate({ title, description }) { + return html`
+
+
+
+
+
+
+ +
+
+
+
${title}
+
+ ${description} +
+
+ +
+
+
+ + + +
+
+ 6th most starred GitHub project +
+
+
+
+ + + + + + + + + +
+
+ Created and maintained by community +
+
+
+
+ + + + +
+
Up-to-date roadmap
+
+
+
+
+
`; +} + +function getRoadmapImageTemplate({ title, description, image, height, width }) { + return html`
+
+ +
+
+
+ ${title?.replace('&', `{"&"}`)} +
+
+ ${description} +
+
+
+ + +
`; +} + +function getGuideTemplate({ title, description, authorName, authorAvatar }) { + return html`
+
+
+
+
+
+
+ +
+
+
+
+ +
+ ${authorName} +
+
+
${title}
+
+ ${description} +
+
+
+
+
`; +} + +function unescapeHtml(html) { + return html + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function hasSpecialCharacters(str) { + return /[&<>"]/.test(str); +} diff --git a/scripts/roadmap-content.cjs b/scripts/roadmap-content.cjs index bde5375f9..af309b01e 100644 --- a/scripts/roadmap-content.cjs +++ b/scripts/roadmap-content.cjs @@ -48,6 +48,11 @@ function getFilesInFolder(folderPath, fileList = {}) { return fileList; } +/** + * Write the topic content for the given topic + * @param currTopicUrl + * @returns {Promise} + */ function writeTopicContent(currTopicUrl) { const [parentTopic, childTopic] = currTopicUrl .replace(/^\d+-/g, '/') @@ -59,9 +64,18 @@ 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. 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.`; + let prompt = `I will give you a topic and you need to write a brief introduction for that with regards to "${roadmapTitle}". Your format should be as follows and be in strictly markdown format: + +# (Put a heading for the topic) + +(Write me a brief introduction for the topic with regards to "${roadmapTitle}") + +`; + 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. 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.`; + prompt += `First topic is: ${parentTopic}`; + } else { + prompt += `First topic is: ${childTopic} under ${parentTopic}`; } console.log(`Generating '${childTopic || parentTopic}'...`); @@ -123,10 +137,9 @@ async function writeFileForGroup(group, topicUrlToPathMapping) { } const topicContent = await writeTopicContent(currTopicUrl); - newFileContent += `\n\n${topicContent}`; console.log(`Writing ${topicId}..`); - fs.writeFileSync(contentFilePath, newFileContent, 'utf8'); + fs.writeFileSync(contentFilePath, topicContent, 'utf8'); // console.log(currentFileContent); // console.log(currTopicUrl); diff --git a/src/components/AIAnnouncement.tsx b/src/components/AIAnnouncement.tsx new file mode 100644 index 000000000..481e9cfcf --- /dev/null +++ b/src/components/AIAnnouncement.tsx @@ -0,0 +1,16 @@ +type AIAnnouncementProps = {}; + +export function AIAnnouncement(props: AIAnnouncementProps) { + return ( + + + New + {' '} + Generate visual roadmaps with AI + AI Roadmap Generator! + + ); +} diff --git a/src/components/Activity/ActivityPage.tsx b/src/components/Activity/ActivityPage.tsx index e76ffa4d7..482fd27e5 100644 --- a/src/components/Activity/ActivityPage.tsx +++ b/src/components/Activity/ActivityPage.tsx @@ -80,6 +80,25 @@ export function ActivityPage() { return null; } + const learningRoadmapsToShow = learningRoadmaps + .sort((a, b) => { + const updatedAtA = new Date(a.updatedAt); + const updatedAtB = new Date(b.updatedAt); + + return updatedAtB.getTime() - updatedAtA.getTime(); + }) + .filter((roadmap) => roadmap.learning > 0 || roadmap.done > 0); + + const learningBestPracticesToShow = learningBestPractices + .sort((a, b) => { + const updatedAtA = new Date(a.updatedAt); + const updatedAtB = new Date(b.updatedAt); + + return updatedAtB.getTime() - updatedAtA.getTime(); + }) + .filter((bestPractice) => bestPractice.learning > 0 || bestPractice.done > 0); + + return ( <>
- {learningRoadmaps.length === 0 && - learningBestPractices.length === 0 && } + {learningRoadmapsToShow.length === 0 && + learningBestPracticesToShow.length === 0 && } - {(learningRoadmaps.length > 0 || learningBestPractices.length > 0) && ( + {(learningRoadmapsToShow.length > 0 || learningBestPracticesToShow.length > 0) && ( <>

Continue Following @@ -105,27 +124,38 @@ export function ActivityPage() { return updatedAtB.getTime() - updatedAtA.getTime(); }) - .map((roadmap) => ( - { - pageProgressMessage.set('Updating activity'); - loadActivity().finally(() => { - pageProgressMessage.set(''); - }); - }} - /> - ))} + .filter((roadmap) => roadmap.learning > 0 || roadmap.done > 0) + .map((roadmap) => { + const learningCount = roadmap.learning || 0; + const doneCount = roadmap.done || 0; + const totalCount = roadmap.total || 0; + const skippedCount = roadmap.skipped || 0; + + return ( + totalCount ? totalCount : doneCount + } + learningCount={ + learningCount > totalCount ? totalCount : learningCount + } + totalCount={totalCount} + skippedCount={skippedCount} + resourceId={roadmap.id} + resourceType={'roadmap'} + updatedAt={roadmap.updatedAt} + title={roadmap.title} + onCleared={() => { + pageProgressMessage.set('Updating activity'); + loadActivity().finally(() => { + pageProgressMessage.set(''); + }); + }} + /> + ); + })} {learningBestPractices .sort((a, b) => { @@ -134,6 +164,10 @@ export function ActivityPage() { return updatedAtB.getTime() - updatedAtA.getTime(); }) + .filter( + (bestPractice) => + bestPractice.learning > 0 || bestPractice.done > 0, + ) .map((bestPractice) => ( { @@ -105,7 +101,7 @@ export function GitHubButton(props: GitHubButtonProps) { // 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( + const pagePath = ['/respond-invite', '/befriend', '/r', '/ai'].includes( window.location.pathname, ) ? window.location.pathname + window.location.search diff --git a/src/components/AuthenticationFlow/GoogleButton.tsx b/src/components/AuthenticationFlow/GoogleButton.tsx index 4ccc917ac..60b0dcf84 100644 --- a/src/components/AuthenticationFlow/GoogleButton.tsx +++ b/src/components/AuthenticationFlow/GoogleButton.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; 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'; import { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx'; @@ -69,11 +69,7 @@ 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) => { @@ -101,7 +97,7 @@ 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( + const pagePath = ['/respond-invite', '/befriend', '/r', '/ai'].includes( window.location.pathname, ) ? window.location.pathname + window.location.search diff --git a/src/components/AuthenticationFlow/LinkedInButton.tsx b/src/components/AuthenticationFlow/LinkedInButton.tsx index e48481a86..6f36c319b 100644 --- a/src/components/AuthenticationFlow/LinkedInButton.tsx +++ b/src/components/AuthenticationFlow/LinkedInButton.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; 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'; import { LinkedInIcon } from '../ReactIcons/LinkedInIcon.tsx'; @@ -69,11 +69,7 @@ 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) => { @@ -101,7 +97,7 @@ 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( + const pagePath = ['/respond-invite', '/befriend', '/r', '/ai'].includes( window.location.pathname, ) ? window.location.pathname + window.location.search diff --git a/src/components/AuthenticationFlow/ResetPasswordForm.tsx b/src/components/AuthenticationFlow/ResetPasswordForm.tsx index 46dd404ce..eaf378388 100644 --- a/src/components/AuthenticationFlow/ResetPasswordForm.tsx +++ b/src/components/AuthenticationFlow/ResetPasswordForm.tsx @@ -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 = '/'; }; diff --git a/src/components/AuthenticationFlow/TriggerVerifyAccount.tsx b/src/components/AuthenticationFlow/TriggerVerifyAccount.tsx index e46342a11..c9442ab52 100644 --- a/src/components/AuthenticationFlow/TriggerVerifyAccount.tsx +++ b/src/components/AuthenticationFlow/TriggerVerifyAccount.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import Cookies from 'js-cookie'; import { httpPost } from '../../lib/http'; -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'; @@ -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) => { diff --git a/src/components/AuthenticationFlow/TriggerVerifyEmail.tsx b/src/components/AuthenticationFlow/TriggerVerifyEmail.tsx new file mode 100644 index 000000000..9baaac6af --- /dev/null +++ b/src/components/AuthenticationFlow/TriggerVerifyEmail.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import { httpPatch } from '../../lib/http'; +import { setAuthToken } from '../../lib/jwt'; +import { Spinner } from '../ReactIcons/Spinner'; +import { ErrorIcon2 } from '../ReactIcons/ErrorIcon2'; +import { getUrlParams } from '../../lib/browser'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; + +export function TriggerVerifyEmail() { + const { code } = getUrlParams() as { code: string }; + + // const [isLoading, setIsLoading] = useState(true); + const [status, setStatus] = useState<'loading' | 'error' | 'success'>( + 'loading', + ); + const [error, setError] = useState(''); + + const triggerVerify = (code: string) => { + setStatus('loading'); + + httpPatch<{ token: string }>( + `${import.meta.env.PUBLIC_API_URL}/v1-verify-new-email/${code}`, + {}, + ) + .then(({ response, error }) => { + if (!response?.token) { + setError(error?.message || 'Something went wrong. Please try again.'); + setStatus('error'); + + return; + } + + setAuthToken(response.token); + setStatus('success'); + }) + .catch((err) => { + setStatus('error'); + setError('Something went wrong. Please try again.'); + }); + }; + + useEffect(() => { + if (!code) { + setStatus('error'); + setError('Something went wrong. Please try again later.'); + return; + } + + triggerVerify(code); + }, [code]); + + const isLoading = status === 'loading'; + if (status === 'success') { + return ( +
+ +

+ Email Update Successful +

+

+ Your email has been changed successfully. Happy learning! +

+
+ ); + } + + return ( +
+
+ {isLoading && } + {error && } +

+ Verifying your new Email +

+
+ {isLoading &&

Please wait while we verify your new Email..

} + {error &&

{error}

} +
+
+
+ ); +} diff --git a/src/components/CustomRoadmap/CustomRoadmap.tsx b/src/components/CustomRoadmap/CustomRoadmap.tsx index bce4914a1..d04af93b6 100644 --- a/src/components/CustomRoadmap/CustomRoadmap.tsx +++ b/src/components/CustomRoadmap/CustomRoadmap.tsx @@ -1,17 +1,11 @@ import { useEffect, useState } from 'react'; import { getUrlParams } from '../../lib/browser'; -import { - type AppError, - type FetchError, - httpGet, - httpPost, -} from '../../lib/http'; +import { type AppError, type FetchError, httpGet } from '../../lib/http'; import { RoadmapHeader } from './RoadmapHeader'; import { TopicDetail } from '../TopicDetail/TopicDetail'; import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; import { currentRoadmap } from '../../stores/roadmap'; import { RestrictedPage } from './RestrictedPage'; -import { isLoggedIn } from '../../lib/jwt'; import { FlowRoadmapRenderer } from './FlowRoadmapRenderer'; export const allowedLinkTypes = [ diff --git a/src/components/CustomRoadmap/CustomRoadmapAlert.tsx b/src/components/CustomRoadmap/CustomRoadmapAlert.tsx new file mode 100644 index 000000000..12c4119ae --- /dev/null +++ b/src/components/CustomRoadmap/CustomRoadmapAlert.tsx @@ -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 && ( + { + setIsCreatingRoadmap(false); + }} + /> + )} +
+

+ Community Roadmap +

+

+ This is a custom roadmap made by a community member and is not verified by{' '} + roadmap.sh +

+
+ + + Visit Official Roadmaps + + · + +
+ + +
+ + ); +} diff --git a/src/components/CustomRoadmap/FlowRoadmapRenderer.tsx b/src/components/CustomRoadmap/FlowRoadmapRenderer.tsx index b014ddc94..3ba55d01b 100644 --- a/src/components/CustomRoadmap/FlowRoadmapRenderer.tsx +++ b/src/components/CustomRoadmap/FlowRoadmapRenderer.tsx @@ -125,6 +125,32 @@ export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) { } }, []); + const handleChecklistCheckboxClick = useCallback( + (e: MouseEvent, checklistId: string) => { + const target = e?.currentTarget as HTMLDivElement; + if (!target) { + return; + } + + const isCurrentStatusDone = target?.classList.contains('done'); + updateTopicStatus(checklistId, isCurrentStatusDone ? 'pending' : 'done'); + }, + [], + ); + + const handleChecklistLabelClick = useCallback( + (e: MouseEvent, checklistId: string) => { + const target = e?.currentTarget as HTMLDivElement; + if (!target) { + return; + } + + const isCurrentStatusDone = target?.classList.contains('done'); + updateTopicStatus(checklistId, isCurrentStatusDone ? 'pending' : 'done'); + }, + [], + ); + return ( <> {hideRenderer && ( @@ -162,6 +188,8 @@ export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) { onTopicAltClick={handleTopicAltClick} onButtonNodeClick={handleLinkClick} onLinkClick={handleLinkClick} + onChecklistCheckboxClick={handleChecklistCheckboxClick} + onChecklistLableClick={handleChecklistLabelClick} fontFamily="Balsamiq Sans" fontURL="/fonts/balsamiq.woff2" /> diff --git a/src/components/CustomRoadmap/RoadmapHeader.tsx b/src/components/CustomRoadmap/RoadmapHeader.tsx index fd7b2f503..9d1e056ed 100644 --- a/src/components/CustomRoadmap/RoadmapHeader.tsx +++ b/src/components/CustomRoadmap/RoadmapHeader.tsx @@ -12,6 +12,7 @@ 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 = {}; @@ -91,6 +92,8 @@ export function RoadmapHeader(props: RoadmapHeaderProps) { return (
+ {!$canManageCurrentRoadmap && } + {creator?.name && (
+ {roadmaps.map((roadmap) => { + const roadmapLink = `/ai?id=${roadmap._id}`; + return ( + +

+ {roadmap.title} +

+
+ + + {Intl.NumberFormat('en-US', { + notation: 'compact', + }).format(roadmap.viewCount)}{' '} + views + + + {getRelativeTimeString(String(roadmap?.createdAt))} + +
+
+ ); + })} + + ); +} diff --git a/src/components/ExploreAIRoadmap/EmptyRoadmaps.tsx b/src/components/ExploreAIRoadmap/EmptyRoadmaps.tsx new file mode 100644 index 000000000..cd175c506 --- /dev/null +++ b/src/components/ExploreAIRoadmap/EmptyRoadmaps.tsx @@ -0,0 +1,31 @@ +import { Map, Wand2 } from 'lucide-react'; + +export function EmptyRoadmaps() { + return ( +
+ +

+ No Roadmaps Found +

+

+ Try searching for something else or create a new roadmap with AI. +

+ +
+ ); +} diff --git a/src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx b/src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx new file mode 100644 index 000000000..f24e9504d --- /dev/null +++ b/src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState } from 'react'; +import { useToast } from '../../hooks/use-toast'; +import { httpGet } from '../../lib/http'; +import { AIRoadmapAlert } from '../GenerateRoadmap/AIRoadmapAlert.tsx'; +import { ExploreAISorting, type SortByValues } from './ExploreAISorting.tsx'; +import { + deleteUrlParam, + getUrlParams, + setUrlParams, +} from '../../lib/browser.ts'; +import { Pagination } from '../Pagination/Pagination.tsx'; +import { LoadingRoadmaps } from './LoadingRoadmaps.tsx'; +import { EmptyRoadmaps } from './EmptyRoadmaps.tsx'; +import { AIRoadmapsList } from './AIRoadmapsList.tsx'; +import { ExploreAISearch } from './ExploreAISearch.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; +}; + +type QueryParams = { + q?: string; + s?: SortByValues; + p?: string; +}; + +type PageState = { + searchTerm: string; + sortBy: SortByValues; + currentPage: number; +}; + +export function ExploreAIRoadmap() { + const toast = useToast(); + + const [pageState, setPageState] = useState({ + searchTerm: '', + sortBy: 'createdAt', + currentPage: 0, + }); + + useEffect(() => { + const queryParams = getUrlParams() as QueryParams; + + setPageState({ + searchTerm: queryParams.q || '', + sortBy: queryParams.s || 'createdAt', + currentPage: +(queryParams.p || '1'), + }); + }, []); + + useEffect(() => { + setIsLoading(true); + if (!pageState.currentPage) { + return; + } + + // only set the URL params if the user modified anything + if ( + pageState.currentPage !== 1 || + pageState.searchTerm !== '' || + pageState.sortBy !== 'createdAt' + ) { + setUrlParams({ + q: pageState.searchTerm, + s: pageState.sortBy, + p: String(pageState.currentPage), + }); + } else { + deleteUrlParam('q'); + deleteUrlParam('s'); + deleteUrlParam('p'); + } + + loadAIRoadmaps( + pageState.currentPage, + pageState.searchTerm, + pageState.sortBy, + ).finally(() => { + setIsLoading(false); + }); + }, [pageState]); + + const [isLoading, setIsLoading] = useState(true); + const [roadmapsResponse, setRoadmapsResponse] = + useState(null); + + const loadAIRoadmaps = async ( + currPage: number = 1, + searchTerm: string = '', + sortBy: SortByValues = 'createdAt', + ) => { + const { response, error } = await httpGet( + `${import.meta.env.PUBLIC_API_URL}/v1-list-ai-roadmaps`, + { + currPage, + ...(searchTerm && { term: searchTerm }), + ...(sortBy && { sortBy }), + }, + ); + + if (error || !response) { + toast.error(error?.message || 'Something went wrong'); + return; + } + + setRoadmapsResponse(response); + }; + + const roadmaps = roadmapsResponse?.data || []; + + const loadingIndicator = isLoading && ; + const emptyRoadmaps = !isLoading && roadmaps.length === 0 && ( + + ); + + const roadmapsList = !isLoading && roadmaps.length > 0 && ( + <> + + { + setPageState({ + ...pageState, + currentPage: page, + }); + }} + /> + + ); + + return ( +
+ + +
+ { + setPageState({ + ...pageState, + searchTerm: term, + currentPage: 1, + }); + }} + /> + + { + setPageState({ + ...pageState, + sortBy, + currentPage: 1, + }); + }} + /> +
+ + {loadingIndicator} + {emptyRoadmaps} + {roadmapsList} +
+ ); +} diff --git a/src/components/ExploreAIRoadmap/ExploreAISearch.tsx b/src/components/ExploreAIRoadmap/ExploreAISearch.tsx new file mode 100644 index 000000000..cb3a5999b --- /dev/null +++ b/src/components/ExploreAIRoadmap/ExploreAISearch.tsx @@ -0,0 +1,69 @@ +import { Search } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useDebounceValue } from '../../hooks/use-debounce.ts'; +import { Spinner } from '../ReactIcons/Spinner.tsx'; + +type ExploreAISearchProps = { + value: string; + total: number; + isLoading: boolean; + onSubmit: (search: string) => void; +}; + +export function ExploreAISearch(props: ExploreAISearchProps) { + const { onSubmit, isLoading = false, total, value: defaultValue } = props; + + const [term, setTerm] = useState(defaultValue); + const debouncedTerm = useDebounceValue(term, 500); + + useEffect(() => { + setTerm(defaultValue); + }, [defaultValue]); + + useEffect(() => { + if (debouncedTerm && debouncedTerm.length < 3) { + return; + } + + if (debouncedTerm === defaultValue) { + return; + } + + onSubmit(debouncedTerm); + }, [debouncedTerm]); + + return ( +
+
+ + setTerm(e.target.value)} + /> + {isLoading && ( + + + + )} +
+ {total > 0 && ( +

+ {Intl.NumberFormat('en-US', { + notation: 'compact', + }).format(total)}{' '} + results found +

+ )} +
+ ); +} diff --git a/src/components/ExploreAIRoadmap/ExploreAISorting.tsx b/src/components/ExploreAIRoadmap/ExploreAISorting.tsx new file mode 100644 index 000000000..47e11f0c6 --- /dev/null +++ b/src/components/ExploreAIRoadmap/ExploreAISorting.tsx @@ -0,0 +1,73 @@ +import { ArrowDownWideNarrow, Check, ChevronDown } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useOutsideClick } from '../../hooks/use-outside-click'; + +export type SortByValues = 'viewCount' | 'createdAt' | '-createdAt'; +const sortingLabels: { label: string; value: SortByValues }[] = [ + { + label: 'Most Viewed', + value: 'viewCount', + }, + { + label: 'Newest', + value: 'createdAt', + }, + { + label: 'Oldest', + value: '-createdAt', + }, +]; + +type ExploreAISortingProps = { + sortBy: SortByValues; + onSortChange: (sortBy: SortByValues) => void; +}; + +export function ExploreAISorting(props: ExploreAISortingProps) { + const { sortBy, onSortChange } = props; + + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const selectedValue = sortingLabels.find((item) => item.value === sortBy); + + useOutsideClick(dropdownRef, () => { + setIsOpen(false); + }); + + return ( +
+ + + {isOpen && ( +
+ {sortingLabels.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/ExploreAIRoadmap/LoadingRoadmaps.tsx b/src/components/ExploreAIRoadmap/LoadingRoadmaps.tsx new file mode 100644 index 000000000..b39d5b79c --- /dev/null +++ b/src/components/ExploreAIRoadmap/LoadingRoadmaps.tsx @@ -0,0 +1,12 @@ +export function LoadingRoadmaps() { + return ( +
    + {new Array(21).fill(0).map((_, index) => ( +
  • + ))} +
+ ); +} diff --git a/src/components/FeaturedItems/FeaturedItem.astro b/src/components/FeaturedItems/FeaturedItem.astro index 2636fed9b..73c424b40 100644 --- a/src/components/FeaturedItems/FeaturedItem.astro +++ b/src/components/FeaturedItems/FeaturedItem.astro @@ -48,10 +48,11 @@ const { { isNew && ( - + + New ) } diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 33f0f0da5..80cbed1d0 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -48,10 +48,10 @@ import Icon from './AstroIcon.astro'; by - + Kamran Ahmed

diff --git a/src/components/FrameRenderer/renderer.ts b/src/components/FrameRenderer/renderer.ts index 82172420c..bbc0848be 100644 --- a/src/components/FrameRenderer/renderer.ts +++ b/src/components/FrameRenderer/renderer.ts @@ -14,6 +14,7 @@ import type { import { pageProgressMessage } from '../../stores/page'; import { showLoginPopup } from '../../lib/popup'; import { replaceChildren } from '../../lib/dom.ts'; +import {setUrlParams} from "../../lib/browser.ts"; export class Renderer { resourceId: string; @@ -141,19 +142,8 @@ export class Renderer { const newJsonFileSlug = newJsonUrl.split('/').pop()?.replace('.json', ''); - // Update the URL and attach the new roadmap type - if (window?.history?.pushState) { - const url = new URL(window.location.href); - const type = this.resourceType[0]; // r for roadmap, b for best-practices - - url.searchParams.delete(type); - - if (newJsonFileSlug !== this.resourceId) { - url.searchParams.set(type, newJsonFileSlug!); - } - - window.history.pushState(null, '', url.toString()); - } + const type = this.resourceType[0]; // r for roadmap, b for best-practices + setUrlParams({ [type]: newJsonFileSlug! }) this.jsonToSvg(newJsonUrl)?.then(() => {}); } diff --git a/src/components/GenerateRoadmap/AIRoadmapAlert.tsx b/src/components/GenerateRoadmap/AIRoadmapAlert.tsx new file mode 100644 index 000000000..4a76c9d7d --- /dev/null +++ b/src/components/GenerateRoadmap/AIRoadmapAlert.tsx @@ -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 ( +
+

+ AI Generated Roadmap{isListing ? 's' : ''}{' '} + + Beta + +

+

+ {isListing + ? 'These are AI generated roadmaps and are not verified by' + : 'This is an AI generated roadmap and is not verified by'}{' '} + roadmap.sh. We are currently in + beta and working hard to improve the quality of the generated roadmaps. +

+

+ {isListing ? ( + + + Create your own Roadmap with AI + + ) : ( + + + Explore other AI Roadmaps + + )} + + + Visit Official Roadmaps + +

+
+ ); +} diff --git a/src/components/GenerateRoadmap/AITermSuggestionInput.tsx b/src/components/GenerateRoadmap/AITermSuggestionInput.tsx new file mode 100644 index 000000000..f6670e264 --- /dev/null +++ b/src/components/GenerateRoadmap/AITermSuggestionInput.tsx @@ -0,0 +1,284 @@ +import { + type InputHTMLAttributes, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { cn } from '../../lib/classname'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { useDebounceValue } from '../../hooks/use-debounce'; +import { httpGet } from '../../lib/http'; +import { useToast } from '../../hooks/use-toast'; +import { Spinner } from '../ReactIcons/Spinner.tsx'; +import type { PageType } from '../CommandMenu/CommandMenu.tsx'; + +type GetTopAIRoadmapTermResponse = { + _id: string; + term: string; + title: string; + isOfficial: boolean; +}[]; + +type AITermSuggestionInputProps = { + value: string; + onValueChange: (value: string) => void; + onSelect?: (roadmapId: string, roadmapTitle: string) => void; + inputClassName?: string; + wrapperClassName?: string; + placeholder?: string; +} & Omit< + InputHTMLAttributes, + 'onSelect' | 'onChange' | 'className' +>; + +export function AITermSuggestionInput(props: AITermSuggestionInputProps) { + const { + value: defaultValue, + onValueChange, + onSelect, + inputClassName, + wrapperClassName, + placeholder, + ...inputProps + } = props; + + const termCache = useMemo( + () => new Map(), + [], + ); + const [officialRoadmaps, setOfficialRoadmaps] = + useState([]); + + const toast = useToast(); + const searchInputRef = useRef(null); + const dropdownRef = useRef(null); + const isFirstRender = useRef(true); + + const [isActive, setIsActive] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [searchResults, setSearchResults] = + useState([]); + const [searchedText, setSearchedText] = useState(defaultValue); + const [activeCounter, setActiveCounter] = useState(0); + const debouncedSearchValue = useDebounceValue(searchedText, 300); + + const loadTopAIRoadmapTerm = async () => { + const trimmedValue = debouncedSearchValue.trim(); + if (trimmedValue.length === 0) { + return []; + } + + if (termCache.has(trimmedValue)) { + const cachedData = termCache.get(trimmedValue); + return cachedData || []; + } + + const { response, error } = await httpGet( + `${import.meta.env.PUBLIC_API_URL}/v1-get-top-ai-roadmap-term`, + { + term: trimmedValue, + }, + ); + + if (error || !response) { + toast.error(error?.message || 'Something went wrong'); + setSearchResults([]); + return []; + } + + termCache.set(trimmedValue, response); + return response; + }; + + const loadOfficialRoadmaps = async () => { + if (officialRoadmaps.length > 0) { + return officialRoadmaps; + } + + const { error, response } = await httpGet(`/pages.json`); + + if (error) { + toast.error(error.message || 'Something went wrong'); + return; + } + + if (!response) { + return []; + } + + const allRoadmaps = response + .filter((page) => page.group === 'Roadmaps') + .sort((a, b) => { + if (a.title === 'Android') return 1; + return a.title.localeCompare(b.title); + }) + .map((page) => ({ + _id: page.id, + term: page.title, + title: page.title, + isOfficial: true, + })); + + setOfficialRoadmaps(allRoadmaps); + return allRoadmaps; + }; + + useEffect(() => { + if (debouncedSearchValue.length === 0 || isFirstRender.current) { + setSearchResults([]); + return; + } + + setIsActive(true); + setIsLoading(true); + loadTopAIRoadmapTerm() + .then((results) => { + const normalizedSearchText = debouncedSearchValue.trim().toLowerCase(); + const matchingOfficialRoadmaps = officialRoadmaps.filter((roadmap) => { + return ( + roadmap.title.toLowerCase().indexOf(normalizedSearchText) !== -1 + ); + }); + + setSearchResults( + [...matchingOfficialRoadmaps, ...results]?.slice(0, 5) || [], + ); + setActiveCounter(0); + }) + .finally(() => { + setIsLoading(false); + }); + }, [debouncedSearchValue]); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + loadOfficialRoadmaps().finally(() => {}); + } + }, []); + + useOutsideClick(dropdownRef, () => { + setIsActive(false); + }); + + const isFinishedTyping = debouncedSearchValue === searchedText; + + return ( +
+ { + const value = (e.target as HTMLInputElement).value; + setSearchedText(value); + onValueChange(value); + }} + onFocus={() => { + setIsActive(true); + }} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + const canGoNext = activeCounter < searchResults.length - 1; + setActiveCounter(canGoNext ? activeCounter + 1 : 0); + } else if (e.key === 'ArrowUp') { + const canGoPrev = activeCounter > 0; + setActiveCounter( + canGoPrev ? activeCounter - 1 : searchResults.length - 1, + ); + } else if (e.key === 'Tab') { + if (isActive) { + e.preventDefault(); + } + } else if (e.key === 'Escape') { + setSearchedText(''); + setIsActive(false); + } else if (e.key === 'Enter') { + if (!searchResults.length || !isFinishedTyping) { + return; + } + + e.preventDefault(); + const activeData = searchResults[activeCounter]; + if (activeData) { + if (activeData.isOfficial) { + window.open(`/${activeData._id}`, '_blank')?.focus(); + return; + } + + onValueChange(activeData.term); + onSelect?.(activeData._id, activeData.title); + setIsActive(false); + } + } + }} + /> + + {isLoading && ( +
+ +
+ )} + + {isActive && + isFinishedTyping && + searchResults.length > 0 && + searchedText.length > 0 && ( +
+
+ {searchResults.map((result, counter) => { + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.css b/src/components/GenerateRoadmap/GenerateRoadmap.css new file mode 100644 index 000000000..d30ca465a --- /dev/null +++ b/src/components/GenerateRoadmap/GenerateRoadmap.css @@ -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; +} diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.tsx b/src/components/GenerateRoadmap/GenerateRoadmap.tsx new file mode 100644 index 000000000..621150ace --- /dev/null +++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx @@ -0,0 +1,707 @@ +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, + setAIReferralCode, + 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'; +import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts'; +import { AITermSuggestionInput } from './AITermSuggestionInput.tsx'; +import { useParams } from '../../hooks/use-params.ts'; +import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx'; +import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.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(null); + + const { id: roadmapId, rc: referralCode } = getUrlParams() as { + id: string; + rc?: string; + }; + const toast = useToast(); + + const [hasSubmitted, setHasSubmitted] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingResults, setIsLoadingResults] = useState(false); + const [roadmapTerm, setRoadmapTerm] = useState(''); + const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState(''); + const [currentRoadmap, setCurrentRoadmap] = + useState(null); + const [selectedNode, setSelectedNode] = useState( + 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, setOpenAPIKey] = useState( + getOpenAIKey(), + ); + const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION; + const isAuthenticatedUser = isLoggedIn(); + + 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); + + 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, ''); + const roadmapTitle = + result.trim().split('\n')[0]?.replace('#', '')?.trim() || term; + setRoadmapTerm(roadmapTitle); + setCurrentRoadmap({ + id: roadmapId, + term: roadmapTerm, + title: roadmapTitle, + 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) => { + e.preventDefault(); + if (!roadmapTerm || isLoadingResults) { + return; + } + + if (roadmapTerm === currentRoadmap?.term) { + 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 () => { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + 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( + `${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<{ + term: string; + title: 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(term); + setGeneratedRoadmapContent(data); + visitAIRoadmap(roadmapId); + }; + + const handleNodeClick = useCallback( + (e: 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 (!referralCode || isLoggedIn()) { + deleteUrlParam('rc'); + return; + } + + setAIReferralCode(referralCode); + deleteUrlParam('rc'); + showLoginPopup(); + }, []); + + useEffect(() => { + if (!roadmapId || roadmapId === currentRoadmap?.id) { + return; + } + + setHasSubmitted(true); + loadAIRoadmap(roadmapId).finally(() => { + pageProgressMessage.set(''); + }); + }, [roadmapId, currentRoadmap]); + + if (!hasSubmitted) { + return ( + { + setRoadmapTerm(term); + loadTermRoadmap(term).finally(() => {}); + }} + /> + ); + } + + const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`; + const canGenerateMore = roadmapLimitUsed < roadmapLimit; + + return ( + <> + {isConfiguring && ( + { + setOpenAPIKey(getOpenAIKey()); + setIsConfiguring(false); + loadAIRoadmapLimit().finally(() => null); + }} + /> + )} + + {selectedNode && currentRoadmap && !isLoading && ( + { + setSelectedNode(null); + setIsConfiguring(true); + }} + onClose={() => { + setSelectedNode(null); + loadAIRoadmapLimit().finally(() => {}); + }} + roadmapId={currentRoadmap?.id || ''} + topicLimit={roadmapTopicLimit} + topicLimitUsed={roadmapTopicLimitUsed} + onTopicContentGenerateComplete={async () => { + await loadAIRoadmapLimit(); + }} + /> + )} + +
+
+ {isLoading && ( + + + Generating roadmap .. + + )} + {!isLoading && ( +
+ + {isKeyOnly && isAuthenticatedUser && ( +
+ {!openAPIKey && ( +

+ We have hit the limit for AI roadmap generation. Please + try again tomorrow or{' '} + +

+ )} + {openAPIKey && ( +

+ You have added your own OpenAI API key.{' '} + +

+ )} +
+ )} + {!isKeyOnly && isAuthenticatedUser && ( +
+ + + {roadmapLimitUsed} of {roadmapLimit} + {' '} + roadmaps generated today. + + {!openAPIKey && ( + + )} + + {openAPIKey && ( + + )} +
+ )} + {!isAuthenticatedUser && ( + + )} +
+ setRoadmapTerm(value)} + placeholder="e.g. Try searching for Ansible or DevOps" + wrapperClassName="grow" + onSelect={(id, title) => { + loadTermRoadmap(title).finally(() => null); + }} + /> + + +
+
+ + {roadmapId && ( + + )} +
+ +
+ + + +
+
+
+ )} +
+
+
+ {!isAuthenticatedUser && ( +
+
+
+
+
+

+ Sign up to View the full roadmap +

+

+ You must be logged in to view the complete roadmap +

+
+
+ + +
+ Already have an account?{' '} + + Login + +
+
+
+
+
+ )} +
+
+ + ); +} diff --git a/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx b/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx new file mode 100644 index 000000000..ca6873b74 --- /dev/null +++ b/src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { cn } from '../../lib/classname'; +import { ChevronUp } from 'lucide-react'; +import { Modal } from '../Modal'; +import { ReferYourFriend } from './ReferYourFriend'; +import { OpenAISettings } from './OpenAISettings'; +import { PayToBypass } from './PayToBypass'; +import { PickLimitOption } from './PickLimitOption'; +import { getOpenAIKey } from '../../lib/jwt.ts'; + +export type IncreaseTab = 'api-key' | 'refer-friends' | 'payment'; + +export const increaseLimitTabs: { + key: IncreaseTab; + title: string; +}[] = [ + { key: 'api-key', title: 'Add your own API Key' }, + { key: 'refer-friends', title: 'Refer your Friends' }, + { key: 'payment', title: 'Pay to Bypass the limit' }, +]; + +type IncreaseRoadmapLimitProps = { + onClose: () => void; +}; +export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) { + const { onClose } = props; + + const openAPIKey = getOpenAIKey(); + const [activeTab, setActiveTab] = useState( + openAPIKey ? 'api-key' : null, + ); + + return ( + + {!activeTab && ( + + )} + + {activeTab === 'api-key' && ( + { + onClose(); + }} + onBack={() => setActiveTab(null)} + /> + )} + {activeTab === 'refer-friends' && ( + setActiveTab(null)} /> + )} + {activeTab === 'payment' && ( + setActiveTab(null)} + onClose={() => { + onClose(); + }} + /> + )} + + ); +} diff --git a/src/components/GenerateRoadmap/OpenAISettings.tsx b/src/components/GenerateRoadmap/OpenAISettings.tsx new file mode 100644 index 000000000..5cb6f897c --- /dev/null +++ b/src/components/GenerateRoadmap/OpenAISettings.tsx @@ -0,0 +1,172 @@ +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'; +import { ChevronLeft } from 'lucide-react'; + +type OpenAISettingsProps = { + onClose: () => void; + onBack: () => void; +}; + +export function OpenAISettings(props: OpenAISettingsProps) { + const { onClose, onBack } = 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 ( +
+ + +

OpenAI Settings

+

+ Add your OpenAI API key below to bypass the roadmap generation limits. + You can use your existing key or{' '} + + create a new one here + + . +

+ +
{ + 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(); + }} + > +
+ { + setHasError(false); + setOpenaiApiKey((e.target as HTMLInputElement).value); + }} + /> + + {openaiApiKey && ( + + )} +
+

+ We do not store your API key on our servers. +

+ + {hasError && ( +

+ Please enter a valid OpenAI API key +

+ )} + + {!defaultOpenAIKey && ( + + )} + {defaultOpenAIKey && ( + + )} +
+
+ ); +} diff --git a/src/components/GenerateRoadmap/PayToBypass.tsx b/src/components/GenerateRoadmap/PayToBypass.tsx new file mode 100644 index 000000000..c535e635f --- /dev/null +++ b/src/components/GenerateRoadmap/PayToBypass.tsx @@ -0,0 +1,164 @@ +import { ChevronLeft } from 'lucide-react'; +import { useAuth } from '../../hooks/use-auth'; + +type PayToBypassProps = { + onBack: () => void; + onClose: () => void; +}; + +export function PayToBypass(props: PayToBypassProps) { + const { onBack, onClose } = props; + const user = useAuth(); + + const userId = 'entry.1665642993'; + const nameId = 'entry.527005328'; + const emailId = 'entry.982906376'; + const amountId = 'entry.1826002937'; + const roadmapCountId = 'entry.1161404075'; + const usageId = 'entry.535914744'; + const feedbackId = 'entry.1024388959'; + + return ( +
+ + +

Pay to Bypass

+

+ Tell us more about how you will be using this. +

+ +
+ + + + +
+ + +
+
+ +