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+)@'); const ROADMAP_SLUG_REGEX = new RegExp(/@ROADMAPSLUG:([\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; }; type GenerateRoadmapProps = { roadmapId?: string; slug?: string; }; export function GenerateRoadmap(props: GenerateRoadmapProps) { const { roadmapId: defaultRoadmapId, slug: defaultRoadmapSlug } = props; const roadmapContainerRef = useRef<HTMLDivElement>(null); const { rc: referralCode } = getUrlParams() as { rc?: string; }; const toast = useToast(); const [roadmapId, setRoadmapId] = useState<string | undefined>( defaultRoadmapId, ); const [roadmapSlug, setRoadmapSlug] = useState<string | undefined>( defaultRoadmapSlug, ); const [hasSubmitted, setHasSubmitted] = useState<boolean>(Boolean(roadmapId)); const [isLoading, setIsLoading] = useState(false); const [isLoadingResults, setIsLoadingResults] = useState(false); const [roadmapTerm, setRoadmapTerm] = useState(''); const [currentRoadmap, setCurrentRoadmap] = useState<GetAIRoadmapResponse | null>(null); const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState(''); const [selectedNode, setSelectedNode] = useState<RoadmapNodeDetails | null>( null, ); const [roadmapLimit, setRoadmapLimit] = useState(0); const [roadmapLimitUsed, setRoadmapLimitUsed] = useState(0); const [roadmapTopicLimit, setRoadmapTopicLimit] = useState(0); const [roadmapTopicLimitUsed, setRoadmapTopicLimitUsed] = useState(0); const [isConfiguring, setIsConfiguring] = useState(false); const [openAPIKey, setOpenAPIKey] = useState<string | undefined>( 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 origin = window.location.origin; window.history.pushState(null, '', `${origin}/ai`); 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') || result.includes('@ROADMAPSLUG')) { // @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] || ''; const roadmapSlug = result.match(ROADMAP_SLUG_REGEX)?.[1] || ''; window.history.pushState(null, '', `${origin}/ai/${roadmapSlug}`); result = result .replace(ROADMAP_ID_REGEX, '') .replace(ROADMAP_SLUG_REGEX, ''); setRoadmapId(roadmapId); setRoadmapSlug(roadmapSlug); 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, '') .replace(ROADMAP_SLUG_REGEX, ''); setGeneratedRoadmapContent(result); loadAIRoadmapLimit().finally(() => {}); }, }); setIsLoading(false); }; const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { 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<GetAIRoadmapLimitResponse>( `${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap-limit`, ); if (error || !response) { toast.error(error?.message || 'Something went wrong'); return; } const { limit, used, topicLimit, topicUsed } = response; setRoadmapLimit(limit); setRoadmapLimitUsed(used); setRoadmapTopicLimit(topicLimit); setRoadmapTopicLimitUsed(topicUsed); }; const loadAIRoadmap = async (roadmapId: string) => { pageProgressMessage.set('Loading Roadmap'); const { response, error } = await httpGet<{ 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<HTMLDivElement, globalThis.MouseEvent>) => { if (isLoading) { return; } const target = e.target as SVGElement; const { nodeId, nodeType, targetGroup, nodeTitle, parentTitle } = getNodeDetails(target) || {}; if ( !nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType) || !nodeTitle ) return; if (nodeType === 'button' || nodeType === 'link-item') { const link = targetGroup?.dataset?.link || ''; const isExternalLink = link.startsWith('http'); if (isExternalLink) { window.open(link, '_blank'); } else { window.location.href = link; } return; } setSelectedNode({ nodeId, nodeType, nodeTitle, ...(nodeType === 'subtopic' && { parentTitle }), }); }, [isLoading], ); useEffect(() => { loadAIRoadmapLimit().finally(() => {}); }, []); useEffect(() => { if (!referralCode || isLoggedIn()) { deleteUrlParam('rc'); return; } setAIReferralCode(referralCode); deleteUrlParam('rc'); showLoginPopup(); }, []); useEffect(() => { if (!roadmapId || roadmapId === currentRoadmap?.id) { return; } loadAIRoadmap(roadmapId).finally(() => { pageProgressMessage.set(''); }); }, [roadmapId, currentRoadmap]); if (!hasSubmitted) { return ( <RoadmapSearch roadmapTerm={roadmapTerm} setRoadmapTerm={setRoadmapTerm} handleSubmit={handleSubmit} limit={roadmapLimit} limitUsed={roadmapLimitUsed} loadAIRoadmapLimit={loadAIRoadmapLimit} isKeyOnly={isKeyOnly} onLoadTerm={(term) => { setRoadmapTerm(term); loadTermRoadmap(term).finally(() => {}); }} /> ); } const pageUrl = `https://roadmap.sh/ai/${roadmapSlug}`; const canGenerateMore = roadmapLimitUsed < roadmapLimit; return ( <> {isConfiguring && ( <IncreaseRoadmapLimit onClose={() => { setOpenAPIKey(getOpenAIKey()); setIsConfiguring(false); loadAIRoadmapLimit().finally(() => null); }} /> )} {selectedNode && currentRoadmap && !isLoading && ( <RoadmapTopicDetail nodeId={selectedNode.nodeId} nodeType={selectedNode.nodeType} nodeTitle={selectedNode.nodeTitle} parentTitle={selectedNode.parentTitle} onConfigureOpenAI={() => { setSelectedNode(null); setIsConfiguring(true); }} onClose={() => { setSelectedNode(null); loadAIRoadmapLimit().finally(() => {}); }} roadmapId={currentRoadmap?.id || ''} topicLimit={roadmapTopicLimit} topicLimitUsed={roadmapTopicLimitUsed} onTopicContentGenerateComplete={async () => { await loadAIRoadmapLimit(); }} /> )} <section className="flex flex-grow flex-col bg-gray-100"> <div className="flex items-center justify-center border-b bg-white py-3 sm:py-6"> {isLoading && ( <span className="flex items-center gap-2 rounded-full bg-black px-3 py-1 text-white"> <Spinner isDualRing={false} innerFill={'white'} /> Generating roadmap .. </span> )} {!isLoading && ( <div className="container flex flex-grow flex-col items-start"> <AIRoadmapAlert /> {isKeyOnly && isAuthenticatedUser && ( <div className="flex flex-row gap-4"> {!openAPIKey && ( <p className={'text-left text-red-500'}> We have hit the limit for AI roadmap generation. Please try again tomorrow or{' '} <button onClick={() => setIsConfiguring(true)} className="font-semibold text-purple-600 underline underline-offset-2" > add your own OpenAI API key </button> </p> )} {openAPIKey && ( <p className={'text-left text-gray-500'}> You have added your own OpenAI API key.{' '} <button onClick={() => setIsConfiguring(true)} className="font-semibold text-purple-600 underline underline-offset-2" > Configure it here if you want. </button> </p> )} </div> )} {!isKeyOnly && isAuthenticatedUser && ( <div className="mt-2 flex w-full flex-col items-start justify-between gap-2 text-sm sm:flex-row sm:items-center sm:gap-0"> <span> <span className={cn( 'mr-0.5 inline-block rounded-xl border px-1.5 text-center text-sm tabular-nums text-gray-800', { 'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300': !roadmapLimit, }, )} > {roadmapLimitUsed} of {roadmapLimit} </span>{' '} roadmaps generated today. </span> {!openAPIKey && ( <button onClick={() => setIsConfiguring(true)} className="rounded-xl border border-current px-2 py-0.5 text-left text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white" > Need to generate more?{' '} <span className="font-semibold">Click here.</span> </button> )} {openAPIKey && ( <button onClick={() => setIsConfiguring(true)} className="flex flex-row items-center gap-1 rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white" > <Cog size={15} /> Configure OpenAI key </button> )} </div> )} {!isAuthenticatedUser && ( <button className="mt-2 rounded-xl border border-current px-2.5 py-0.5 text-left text-sm font-medium text-blue-500 transition-colors hover:bg-blue-500 hover:text-white sm:text-center" onClick={showLoginPopup} > Login to generate your own roadmaps </button> )} <form onSubmit={handleSubmit} className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center" > <AITermSuggestionInput value={roadmapTerm} onValueChange={(value) => setRoadmapTerm(value)} placeholder="e.g. Try searching for Ansible or DevOps" wrapperClassName="grow" onSelect={(id, title) => { loadTermRoadmap(title).finally(() => null); }} /> <button type={'submit'} className={cn( 'flex min-w-[127px] flex-shrink-0 items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-white', 'disabled:cursor-not-allowed disabled:opacity-50', )} onClick={(e) => { if (!isAuthenticatedUser) { e.preventDefault(); showLoginPopup(); } }} disabled={ isLoadingResults || (isAuthenticatedUser && (!roadmapLimit || !roadmapTerm || roadmapLimitUsed >= roadmapLimit || roadmapTerm === currentRoadmap?.term || (isKeyOnly && !openAPIKey))) } > {isLoadingResults && ( <> <span>Please wait..</span> </> )} {!isLoadingResults && ( <> {!isAuthenticatedUser && ( <> <Wand size={20} /> Generate </> )} {isAuthenticatedUser && ( <> {roadmapLimit > 0 && canGenerateMore && ( <> <Wand size={20} /> Generate </> )} {roadmapLimit === 0 && <span>Please wait..</span>} {roadmapLimit > 0 && !canGenerateMore && ( <span className="flex items-center"> <Ban size={15} className="mr-2" /> Limit reached </span> )} </> )} </> )} </button> </form> <div className="flex w-full items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2"> <button className="inline-flex items-center justify-center gap-2 rounded-md bg-yellow-400 py-1.5 pl-2.5 pr-3 text-xs font-medium transition-opacity duration-300 hover:bg-yellow-500 sm:text-sm" onClick={downloadGeneratedRoadmapContent} > <Download size={15} /> Download </button> {roadmapId && ( <ShareRoadmapButton description={`Check out ${roadmapTerm} roadmap I generated on roadmap.sh`} pageUrl={pageUrl} /> )} </div> <div className="flex items-center justify-between gap-2"> <button className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:text-sm" onClick={async () => { const roadmapId = await saveAIRoadmap(); if (roadmapId) { window.location.href = `/r?id=${roadmapId}`; } }} disabled={isLoading} > <Save size={15} /> <span className="hidden sm:inline"> Save and Start Learning </span> <span className="inline sm:hidden">Start Learning</span> </button> <button className="hidden items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:inline-flex sm:text-sm" onClick={async () => { const roadmapId = await saveAIRoadmap(); if (roadmapId) { window.open( `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`, '_blank', ); } }} disabled={isLoading} > <PenSquare size={15} /> Edit in Editor </button> </div> </div> </div> )} </div> <div className={cn({ 'relative mb-20 max-h-[800px] min-h-[800px] overflow-hidden sm:max-h-[1000px] md:min-h-[1000px] lg:max-h-[1200px] lg:min-h-[1200px]': !isAuthenticatedUser, })} > <div ref={roadmapContainerRef} id="roadmap-container" onClick={handleNodeClick} className="relative min-h-[400px] px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]" /> {!isAuthenticatedUser && ( <div className="absolute bottom-0 left-0 right-0"> <div className="h-80 w-full bg-gradient-to-t from-gray-100 to-transparent" /> <div className="bg-gray-100"> <div className="mx-auto max-w-[600px] flex-col items-center justify-center bg-gray-100 px-5 pt-px"> <div className="mt-8 text-center"> <h2 className="mb-0.5 text-xl font-medium sm:mb-3 sm:text-2xl"> Sign up to View the full roadmap </h2> <p className="mb-6 text-balance text-sm text-gray-600 sm:text-base"> You must be logged in to view the complete roadmap </p> </div> <div className="mx-auto max-w-[350px]"> <AuthenticationForm type="signup" /> <div className="mt-6 text-center text-sm text-slate-600"> Already have an account?{' '} <a href="/login" className="font-medium text-blue-700 hover:text-blue-600" > Login </a> </div> </div> </div> </div> </div> )} </div> </section> </> ); }