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 && ( )}
)}
); }