diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.tsx b/src/components/GenerateRoadmap/GenerateRoadmap.tsx index 5164ebba3..80e19832c 100644 --- a/src/components/GenerateRoadmap/GenerateRoadmap.tsx +++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx @@ -1,4 +1,11 @@ -import { type FormEvent, useEffect, useRef, useState } from 'react'; +import { + type FormEvent, + useEffect, + useRef, + useState, + useCallback, + type MouseEvent, +} from 'react'; import './GenerateRoadmap.css'; import { useToast } from '../../hooks/use-toast'; import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator'; @@ -20,9 +27,39 @@ import { import { downloadGeneratedRoadmapImage } from '../../helper/download-image.ts'; import { showLoginPopup } from '../../lib/popup.ts'; import { cn } from '../../lib/classname.ts'; +import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx'; 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; topic: string; @@ -38,9 +75,12 @@ export function GenerateRoadmap() { const [hasSubmitted, setHasSubmitted] = useState(false); const [isLoading, setIsLoading] = useState(false); const [roadmapTopic, setRoadmapTopic] = useState(''); - const [generatedRoadmap, setGeneratedRoadmap] = useState(''); + const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState(''); const [currentRoadmap, setCurrentRoadmap] = useState(null); + const [selectedTopic, setSelectedTopic] = useState( + null, + ); const [roadmapLimit, setRoadmapLimit] = useState(0); const [roadmapLimitUsed, setRoadmapLimitUsed] = useState(0); @@ -128,7 +168,7 @@ export function GenerateRoadmap() { }, onStreamEnd: async (result) => { result = result.replace(ROADMAP_ID_REGEX, ''); - setGeneratedRoadmap(result); + setGeneratedRoadmapContent(result); loadAIRoadmapLimit().finally(() => {}); }, }); @@ -136,7 +176,7 @@ export function GenerateRoadmap() { setIsLoading(false); }; - const editGeneratedRoadmap = async () => { + const editGeneratedRoadmapContent = async () => { if (!isLoggedIn()) { showLoginPopup(); return; @@ -144,7 +184,7 @@ export function GenerateRoadmap() { pageProgressMessage.set('Redirecting to Editor'); - const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmap); + const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmapContent); const { response, error } = await httpPost<{ roadmapId: string; @@ -181,7 +221,7 @@ export function GenerateRoadmap() { ); }; - const downloadGeneratedRoadmap = async () => { + const downloadGeneratedRoadmapContent = async () => { pageProgressMessage.set('Downloading Roadmap'); const node = document.getElementById('roadmap-container'); @@ -238,9 +278,38 @@ export function GenerateRoadmap() { data, }); setRoadmapTopic(topic); - setGeneratedRoadmap(data); + setGeneratedRoadmapContent(data); }; + const handleSvgClick = useCallback( + (e: MouseEvent) => { + const target = e.target as SVGElement; + const { nodeId, nodeType, targetGroup, nodeTitle, parentTitle } = + getNodeDetails(target) || {}; + if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType)) + 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; + } + + setSelectedTopic({ + nodeId, + nodeType, + nodeTitle, + ...(nodeType === 'subtopic' && { parentTitle }), + }); + }, + [], + ); + useEffect(() => { loadAIRoadmapLimit().finally(() => {}); }, []); @@ -272,120 +341,134 @@ export function GenerateRoadmap() { const canGenerateMore = roadmapLimitUsed < roadmapLimit; return ( -
-
- {isLoading && ( - - - Generating roadmap .. - - )} - {!isLoading && ( -
-
- - + {selectedTopic && currentRoadmap && !isLoading && ( + setSelectedTopic(null)} + roadmapId={currentRoadmap?.id || ''} + /> + )} + +
+
+ {isLoading && ( + + + Generating roadmap .. + + )} + {!isLoading && ( +
+
+ + + {roadmapLimitUsed} of {roadmapLimit} + {' '} + roadmaps generated + {!isLoggedIn() && ( + <> + {' '} + + + )} + +
+
+ + setRoadmapTopic((e.target as HTMLInputElement).value) + } + /> + - - )} - -
- - - setRoadmapTopic((e.target as HTMLInputElement).value) - } - /> - - -
-
+ {roadmapLimit > 0 && canGenerateMore && ( + <> + + Generate + + )} + + {roadmapLimit === 0 && Please wait..} + + {roadmapLimit > 0 && !canGenerateMore && ( + + + Limit reached + + )} + + +
+
+ + {roadmapId && ( + + )} +
- {roadmapId && ( - - )}
-
-
- )} -
-
-
+ )} +
+
+
+ ); } diff --git a/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx b/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx new file mode 100644 index 000000000..1737a912d --- /dev/null +++ b/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx @@ -0,0 +1,168 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { useKeydown } from '../../hooks/use-keydown'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { markdownToHtml } from '../../lib/markdown'; +import { Ban, FileText, X } from 'lucide-react'; +import { Spinner } from '../ReactIcons/Spinner'; +import type { RoadmapNodeDetails } from './GenerateRoadmap'; +import { removeAuthToken } from '../../lib/jwt'; +import { readAIRoadmapContentStream } from '../../helper/read-stream'; + +type RoadmapTopicDetailProps = RoadmapNodeDetails & { + onClose?: () => void; + roadmapId: string; +}; + +export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) { + const { onClose, roadmapId, nodeTitle, parentTitle } = props; + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [topicHtml, setTopicHtml] = useState(''); + + const topicRef = useRef(null); + + const abortController = useMemo(() => new AbortController(), []); + const generateAiRoadmapTopicContent = async () => { + setIsLoading(true); + setError(''); + + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-genereate-ai-roadmap-content/${roadmapId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + nodeTitle, + parentTitle, + }), + signal: abortController.signal, + }, + ); + + if (!response.ok) { + const data = await response.json(); + + setError(data?.message || 'Something went wrong'); + setIsLoading(false); + + // Logout user if token is invalid + if (data.status === 401) { + removeAuthToken(); + window.location.reload(); + } + } + const reader = response.body?.getReader(); + + if (!reader) { + setIsLoading(false); + setError('Something went wrong'); + return; + } + + setIsLoading(false); + await readAIRoadmapContentStream(reader, { + onStream: async (result) => { + setTopicHtml(markdownToHtml(result, false)); + }, + }); + }; + + // Close the topic detail when user clicks outside the topic detail + useOutsideClick(topicRef, () => { + onClose?.(); + }); + + useKeydown('Escape', () => { + onClose?.(); + }); + + useEffect(() => { + if (!topicRef?.current) { + return; + } + + topicRef?.current?.focus(); + generateAiRoadmapTopicContent().finally(() => {}); + + return () => { + abortController.abort(); + }; + }, []); + + const hasContent = topicHtml?.length > 0; + + return ( +
+
+ {isLoading && ( +
+ +
+ )} + + {!isLoading && !error && ( + <> +
+ +
+ + {hasContent ? ( +
+
+
+ ) : ( +
+ +

+ Empty Content +

+
+ )} + + )} + + {/* Error */} + {!isLoading && error && ( + <> + +
+ +

{error}

+
+ + )} +
+
+
+ ); +} diff --git a/src/helper/read-stream.ts b/src/helper/read-stream.ts index 422c89c85..2df821443 100644 --- a/src/helper/read-stream.ts +++ b/src/helper/read-stream.ts @@ -41,3 +41,33 @@ export async function readAIRoadmapStream( onStreamEnd?.(result); reader.releaseLock(); } + +export async function readAIRoadmapContentStream( + 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; + } + + if (value) { + result += decoder.decode(value); + onStream?.(result); + } + } + + onStream?.(result); + onStreamEnd?.(result); + reader.releaseLock(); +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 474c08174..b7114f012 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -8,6 +8,27 @@ export function markdownToHtml(markdown: string, isInline = true): string { linkify: true, }); + // Solution to open links in new tab in markdown + // otherwise default behaviour is to open in same tab + // + // SOURCE: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer + // + const defaultRender = + md.renderer.rules.link_open || + // @ts-ignore + function (tokens, idx, options, env, self) { + return self.renderToken(tokens, idx, options); + }; + + // @ts-ignore + md.renderer.rules.link_open = function (tokens, idx, options, env, self) { + // Add a new `target` attribute, or replace the value of the existing one. + tokens[idx].attrSet('target', '_blank'); + + // Pass the token to the default renderer. + return defaultRender(tokens, idx, options, env, self); + }; + if (isInline) { return md.renderInline(markdown); } else {