diff --git a/package.json b/package.json index e2bc8eb11..d4e87dab9 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "astro": "^4.4.0", "astro-compress": "^2.2.10", "clsx": "^2.1.0", + "dom-to-image": "^2.6.0", "dracula-prism": "^2.1.16", "jose": "^5.2.2", "js-cookie": "^3.0.5", @@ -46,15 +47,18 @@ "react-dom": "^18.2.0", "reactflow": "^11.10.4", "rehype-external-links": "^3.0.0", + "remark-parse": "^11.0.0", "roadmap-renderer": "^1.0.6", "slugify": "^1.6.6", "tailwind-merge": "^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", "csv-parser": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7831ce0e..40919589e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: dependencies: '@astrojs/react': specifier: ^3.0.10 - version: 3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3) + version: 3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3) '@astrojs/sitemap': specifier: ^3.0.5 version: 3.0.5 @@ -22,7 +22,7 @@ dependencies: version: 0.7.1(nanostores@0.9.5)(react@18.2.0) '@types/react': specifier: ^18.2.56 - version: 18.2.58 + version: 18.2.59 '@types/react-dom': specifier: ^18.2.19 version: 18.2.19 @@ -35,6 +35,9 @@ dependencies: clsx: specifier: ^2.1.0 version: 2.1.0 + dom-to-image: + specifier: ^2.6.0 + version: 2.6.0 dracula-prism: specifier: ^2.1.16 version: 2.1.16 @@ -73,10 +76,13 @@ dependencies: version: 18.2.0(react@18.2.0) reactflow: specifier: ^11.10.4 - version: 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + version: 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) rehype-external-links: specifier: ^3.0.0 version: 3.0.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 roadmap-renderer: specifier: ^1.0.6 version: 1.0.6 @@ -89,9 +95,12 @@ dependencies: tailwindcss: specifier: ^3.4.1 version: 3.4.1 + unified: + specifier: ^11.0.4 + version: 11.0.4 zustand: specifier: ^4.5.1 - version: 4.5.1(@types/react@18.2.58)(react@18.2.0) + version: 4.5.1(@types/react@18.2.59)(react@18.2.0) devDependencies: '@playwright/test': @@ -100,6 +109,9 @@ devDependencies: '@tailwindcss/typography': specifier: ^0.5.10 version: 0.5.10(tailwindcss@3.4.1) + '@types/dom-to-image': + specifier: ^2.6.7 + version: 2.6.7 '@types/js-cookie': specifier: ^3.0.6 version: 3.0.6 @@ -185,7 +197,7 @@ packages: prismjs: 1.29.0 dev: false - /@astrojs/react@3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3): + /@astrojs/react@3.0.10(@types/react-dom@18.2.19)(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0)(vite@5.1.3): resolution: {integrity: sha512-uGRIwKMAn7tva2vxXMyoVIGxWFr0rjZ8ZWIlkTG/vIpnAjD2nM8Cz6B8j7yzj176jvl6gZ6xTbTVPm09aeK0Yw==} engines: {node: '>=18.14.1'} peerDependencies: @@ -194,7 +206,7 @@ packages: react: ^17.0.2 || ^18.0.0 react-dom: ^17.0.2 || ^18.0.0 dependencies: - '@types/react': 18.2.58 + '@types/react': 18.2.59 '@types/react-dom': 18.2.19 '@vitejs/plugin-react': 4.2.1(vite@5.1.3) react: 18.2.0 @@ -1102,39 +1114,39 @@ packages: config-chain: 1.1.13 dev: false - /@reactflow/background@11.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/background@11.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer dev: false - /@reactflow/controls@11.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/controls@11.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer dev: false - /@reactflow/core@11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/core@11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==} peerDependencies: react: '>=17' @@ -1150,19 +1162,19 @@ packages: d3-zoom: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer dev: false - /@reactflow/minimap@11.7.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/minimap@11.7.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) '@types/d3-selection': 3.0.10 '@types/d3-zoom': 3.0.8 classcat: 5.0.4 @@ -1170,41 +1182,41 @@ packages: d3-zoom: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer dev: false - /@reactflow/node-resizer@2.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/node-resizer@2.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 d3-drag: 3.0.0 d3-selection: 3.0.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer dev: false - /@reactflow/node-toolbar@1.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /@reactflow/node-toolbar@1.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) classcat: 5.0.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - zustand: 4.5.1(@types/react@18.2.58)(react@18.2.0) + zustand: 4.5.1(@types/react@18.2.59)(react@18.2.0) transitivePeerDependencies: - '@types/react' - immer @@ -1618,6 +1630,10 @@ packages: '@types/ms': 0.7.34 dev: false + /@types/dom-to-image@2.6.7: + resolution: {integrity: sha512-me5VbCv+fcXozblWwG13krNBvuEOm6kA5xoa4RrjDJCNFOZSWR3/QLtOXimBHk1Fisq69Gx3JtOoXtg1N1tijg==} + dev: true + /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: false @@ -1700,11 +1716,11 @@ packages: /@types/react-dom@18.2.19: resolution: {integrity: sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==} dependencies: - '@types/react': 18.2.58 + '@types/react': 18.2.59 dev: false - /@types/react@18.2.58: - resolution: {integrity: sha512-TaGvMNhxvG2Q0K0aYxiKfNDS5m5ZsoIBBbtfUorxdH4NGSXIlYvZxLJI+9Dd3KjeB3780bciLyAb7ylO8pLhPw==} + /@types/react@18.2.59: + resolution: {integrity: sha512-DE+F6BYEC8VtajY85Qr7mmhTd/79rJKIHCg99MU9SWPB4xvLb6D1za2vYflgZfmPqQVEr6UqJTnLXEwzpVPuOg==} dependencies: '@types/prop-types': 15.7.11 '@types/scheduler': 0.16.8 @@ -2697,6 +2713,10 @@ packages: entities: 4.5.0 dev: false + /dom-to-image@2.6.0: + resolution: {integrity: sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA==} + dev: false + /domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} dev: false @@ -5543,18 +5563,18 @@ packages: loose-envify: 1.4.0 dev: false - /reactflow@11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0): + /reactflow@11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==} peerDependencies: react: '>=17' react-dom: '>=17' dependencies: - '@reactflow/background': 11.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) - '@reactflow/controls': 11.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) - '@reactflow/core': 11.10.4(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) - '@reactflow/minimap': 11.7.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) - '@reactflow/node-resizer': 2.2.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) - '@reactflow/node-toolbar': 1.3.9(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/background': 11.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/controls': 11.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.10.4(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/minimap': 11.7.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/node-resizer': 2.2.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/node-toolbar': 1.3.9(@types/react@18.2.59)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) transitivePeerDependencies: @@ -6929,7 +6949,7 @@ packages: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false - /zustand@4.5.1(@types/react@18.2.58)(react@18.2.0): + /zustand@4.5.1(@types/react@18.2.59)(react@18.2.0): resolution: {integrity: sha512-XlauQmH64xXSC1qGYNv00ODaQ3B+tNPoy22jv2diYiP4eoDKr9LA+Bh5Bc3gplTrFdb6JVI+N4kc1DZ/tbtfPg==} engines: {node: '>=12.7.0'} peerDependencies: @@ -6944,7 +6964,7 @@ packages: react: optional: true dependencies: - '@types/react': 18.2.58 + '@types/react': 18.2.59 react: 18.2.0 use-sync-external-store: 1.2.0(react@18.2.0) dev: false 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/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..7b1269fc9 --- /dev/null +++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx @@ -0,0 +1,361 @@ +import { type FormEvent, 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 { isLoggedIn, removeAuthToken } from '../../lib/jwt'; +import { RoadmapSearch } from './RoadmapSearch.tsx'; +import { Spinner } from '../ReactIcons/Spinner.tsx'; +import { Ban, Download, PenSquare, 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'; + +const ROADMAP_ID_REGEX = new RegExp('@ROADMAPID:(\\w+)@'); + +export function GenerateRoadmap() { + const roadmapContainerRef = useRef(null); + + const { id: roadmapId } = getUrlParams() as { id: string }; + const toast = useToast(); + + const [hasSubmitted, setHasSubmitted] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [roadmapTopic, setRoadmapTopic] = useState(''); + const [generatedRoadmap, setGeneratedRoadmap] = useState(''); + + const [roadmapLimit, setRoadmapLimit] = useState(0); + const [roadmapLimitUsed, setRoadmapLimitUsed] = useState(0); + + const renderRoadmap = async (roadmap: string) => { + const { nodes, edges } = generateAIRoadmapFromText(roadmap); + const svg = await renderFlowJSON({ nodes, edges }); + if (roadmapContainerRef?.current) { + replaceChildren(roadmapContainerRef?.current, svg); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!roadmapTopic) { + return; + } + + setIsLoading(true); + setHasSubmitted(true); + + if (roadmapLimitUsed >= roadmapLimit) { + toast.error('You have reached your limit of generating roadmaps'); + setIsLoading(false); + return; + } + + deleteUrlParam('id'); + + 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({ topic: roadmapTopic }), + }, + ); + + 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, ''); + } + + await renderRoadmap(result); + }, + onStreamEnd: async (result) => { + result = result.replace(ROADMAP_ID_REGEX, ''); + setGeneratedRoadmap(result); + loadAIRoadmapLimit().finally(() => {}); + }, + }); + + setIsLoading(false); + }; + + const editGeneratedRoadmap = async () => { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + pageProgressMessage.set('Redirecting to Editor'); + + const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmap); + + const { response, error } = await httpPost<{ + roadmapId: string; + }>(`${import.meta.env.PUBLIC_API_URL}/v1-edit-ai-generated-roadmap`, { + title: roadmapTopic, + 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'); + setIsLoading(false); + return; + } + + window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${response.roadmapId}`; + }; + + const downloadGeneratedRoadmap = async () => { + pageProgressMessage.set('Downloading Roadmap'); + + const node = document.getElementById('roadmap-container'); + if (!node) { + toast.error('Something went wrong'); + return; + } + + try { + await downloadGeneratedRoadmapImage(roadmapTopic, node); + pageProgressMessage.set(''); + } catch (error) { + console.error(error); + toast.error('Something went wrong'); + } + }; + + const loadAIRoadmapLimit = async () => { + const { response, error } = await httpGet<{ + limit: number; + used: number; + }>(`${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 } = response; + setRoadmapLimit(limit); + setRoadmapLimitUsed(used); + }; + + const loadAIRoadmap = async (roadmapId: string) => { + pageProgressMessage.set('Loading Roadmap'); + + const { response, error } = await httpGet<{ + topic: string; + data: string; + }>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`); + + if (error || !response) { + toast.error(error?.message || 'Something went wrong'); + setIsLoading(false); + return; + } + + const { topic, data } = response; + await renderRoadmap(data); + + setRoadmapTopic(topic); + setGeneratedRoadmap(data); + }; + + useEffect(() => { + loadAIRoadmapLimit().finally(() => {}); + }, []); + + useEffect(() => { + if (!roadmapId) { + return; + } + + setHasSubmitted(true); + loadAIRoadmap(roadmapId).finally(() => { + pageProgressMessage.set(''); + }); + }, [roadmapId]); + + if (!hasSubmitted) { + return ( + + ); + } + + const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`; + const canGenerateMore = roadmapLimitUsed < roadmapLimit; + + return ( +
+
+ {isLoading && ( + + + Generating roadmap .. + + )} + {!isLoading && ( +
+
+ + + {roadmapLimitUsed} of {roadmapLimit} + {' '} + roadmaps generated + {!isLoggedIn() && ( + <> + {' '} + + + )} + +
+
+ + setRoadmapTopic((e.target as HTMLInputElement).value) + } + /> + +
+
+
+ + {roadmapId && ( + + )} +
+ +
+
+ )} +
+
+
+ ); +} diff --git a/src/components/GenerateRoadmap/RoadmapSearch.tsx b/src/components/GenerateRoadmap/RoadmapSearch.tsx new file mode 100644 index 000000000..0b2ec2bac --- /dev/null +++ b/src/components/GenerateRoadmap/RoadmapSearch.tsx @@ -0,0 +1,121 @@ +import { Ban, Wand } from 'lucide-react'; +import type { FormEvent } from 'react'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { cn } from '../../lib/classname.ts'; + +type RoadmapSearchProps = { + roadmapTopic: string; + setRoadmapTopic: (topic: string) => void; + handleSubmit: (e: FormEvent) => void; + limit: number; + limitUsed: number; +}; + +export function RoadmapSearch(props: RoadmapSearchProps) { + const { + roadmapTopic, + setRoadmapTopic, + handleSubmit, + limit = 0, + limitUsed = 0, + } = props; + + const canGenerateMore = limitUsed < limit; + + return ( +
+
+

+ Generate roadmaps with AI + AI Roadmap Generator +

+

+ + Enter a topic and let the AI generate a roadmap for you + + + Enter a topic to generate a roadmap + +

+
+
{ + if (limit > 0 && canGenerateMore) { + handleSubmit(e); + } else { + e.preventDefault(); + } + }} + className="my-3 flex w-full max-w-[600px] flex-col gap-2 sm:my-5 sm:flex-row" + > + setRoadmapTopic((e.target as HTMLInputElement).value)} + /> + +
+
+

+ Generated + You have generated + + {limitUsed} of {limit} + {' '} + roadmaps. + {!isLoggedIn && ( + <> + {' '} + + + )} +

+
+
+ ); +} diff --git a/src/helper/download-image.ts b/src/helper/download-image.ts index 193128cea..db598a49d 100644 --- a/src/helper/download-image.ts +++ b/src/helper/download-image.ts @@ -34,3 +34,35 @@ export async function downloadImage({ alert('Error downloading image'); } } + +export async function downloadGeneratedRoadmapImage( + name: string, + node: HTMLElement, +) { + // Append a watermark to the bottom right of the image + const watermark = document.createElement('div'); + watermark.className = 'flex justify-end absolute top-4 right-4 gap-2'; + watermark.innerHTML = ` + + roadmap.sh + + `; + node.insertAdjacentElement('afterbegin', watermark); + + const domtoimage = (await import('dom-to-image')).default; + if (!domtoimage) { + throw new Error('Unable to download image'); + } + + const dataUrl = await domtoimage.toJpeg(node, { + bgcolor: 'white', + quality: 1, + }); + node?.removeChild(watermark); + const link = document.createElement('a'); + link.download = `${name}-roadmap.jpg`; + link.href = dataUrl; + link.click(); +} diff --git a/src/helper/read-stream.ts b/src/helper/read-stream.ts new file mode 100644 index 000000000..422c89c85 --- /dev/null +++ b/src/helper/read-stream.ts @@ -0,0 +1,43 @@ +const NEW_LINE = '\n'.charCodeAt(0); + +export async function readAIRoadmapStream( + reader: ReadableStreamDefaultReader, + { + onStream, + onStreamEnd, + }: { + onStream?: (roadmap: string) => void; + onStreamEnd?: (roadmap: string) => void; + }, +) { + const decoder = new TextDecoder('utf-8'); + let result = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + // We will call the renderRoadmap callback whenever we encounter + // a new line with the result until the new line + // otherwise, we will keep appending the result to the previous result + if (value) { + let start = 0; + for (let i = 0; i < value.length; i++) { + if (value[i] === NEW_LINE) { + result += decoder.decode(value.slice(start, i + 1)); + onStream?.(result); + start = i + 1; + } + } + if (start < value.length) { + result += decoder.decode(value.slice(start)); + } + } + } + + onStream?.(result); + onStreamEnd?.(result); + reader.releaseLock(); +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 5baa80f82..ae3adfc96 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -149,7 +149,7 @@ const gaPageIdentifier = Astro.url.pathname ) } - + diff --git a/src/pages/ai/index.astro b/src/pages/ai/index.astro new file mode 100644 index 000000000..149e2064a --- /dev/null +++ b/src/pages/ai/index.astro @@ -0,0 +1,10 @@ +--- +import LoginPopup from '../../components/AuthenticationFlow/LoginPopup.astro'; +import { GenerateRoadmap } from '../../components/GenerateRoadmap/GenerateRoadmap'; +import AccountLayout from '../../layouts/AccountLayout.astro'; +--- + + + + + diff --git a/tsconfig.json b/tsconfig.json index c164c57da..0fb7885fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,5 +4,6 @@ "moduleResolution": "node", "jsx": "react-jsx", "jsxImportSource": "react" - } -} \ No newline at end of file + }, + "exclude": ["node_modules", "dist"] +}