feat: generate roadmap content

fix/ai-roadmap
Arik Chakma 9 months ago
parent 56b327177b
commit f561a52a46
  1. 311
      src/components/GenerateRoadmap/GenerateRoadmap.tsx
  2. 168
      src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
  3. 30
      src/helper/read-stream.ts
  4. 21
      src/lib/markdown.ts

@ -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 './GenerateRoadmap.css';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator'; import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator';
@ -20,9 +27,39 @@ import {
import { downloadGeneratedRoadmapImage } from '../../helper/download-image.ts'; import { downloadGeneratedRoadmapImage } from '../../helper/download-image.ts';
import { showLoginPopup } from '../../lib/popup.ts'; import { showLoginPopup } from '../../lib/popup.ts';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
const ROADMAP_ID_REGEX = new RegExp('@ROADMAPID:(\\w+)@'); 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 = { type GetAIRoadmapResponse = {
id: string; id: string;
topic: string; topic: string;
@ -38,9 +75,12 @@ export function GenerateRoadmap() {
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false); const [hasSubmitted, setHasSubmitted] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [roadmapTopic, setRoadmapTopic] = useState(''); const [roadmapTopic, setRoadmapTopic] = useState('');
const [generatedRoadmap, setGeneratedRoadmap] = useState(''); const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
const [currentRoadmap, setCurrentRoadmap] = const [currentRoadmap, setCurrentRoadmap] =
useState<GetAIRoadmapResponse | null>(null); useState<GetAIRoadmapResponse | null>(null);
const [selectedTopic, setSelectedTopic] = useState<RoadmapNodeDetails | null>(
null,
);
const [roadmapLimit, setRoadmapLimit] = useState(0); const [roadmapLimit, setRoadmapLimit] = useState(0);
const [roadmapLimitUsed, setRoadmapLimitUsed] = useState(0); const [roadmapLimitUsed, setRoadmapLimitUsed] = useState(0);
@ -128,7 +168,7 @@ export function GenerateRoadmap() {
}, },
onStreamEnd: async (result) => { onStreamEnd: async (result) => {
result = result.replace(ROADMAP_ID_REGEX, ''); result = result.replace(ROADMAP_ID_REGEX, '');
setGeneratedRoadmap(result); setGeneratedRoadmapContent(result);
loadAIRoadmapLimit().finally(() => {}); loadAIRoadmapLimit().finally(() => {});
}, },
}); });
@ -136,7 +176,7 @@ export function GenerateRoadmap() {
setIsLoading(false); setIsLoading(false);
}; };
const editGeneratedRoadmap = async () => { const editGeneratedRoadmapContent = async () => {
if (!isLoggedIn()) { if (!isLoggedIn()) {
showLoginPopup(); showLoginPopup();
return; return;
@ -144,7 +184,7 @@ export function GenerateRoadmap() {
pageProgressMessage.set('Redirecting to Editor'); pageProgressMessage.set('Redirecting to Editor');
const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmap); const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmapContent);
const { response, error } = await httpPost<{ const { response, error } = await httpPost<{
roadmapId: string; roadmapId: string;
@ -181,7 +221,7 @@ export function GenerateRoadmap() {
); );
}; };
const downloadGeneratedRoadmap = async () => { const downloadGeneratedRoadmapContent = async () => {
pageProgressMessage.set('Downloading Roadmap'); pageProgressMessage.set('Downloading Roadmap');
const node = document.getElementById('roadmap-container'); const node = document.getElementById('roadmap-container');
@ -238,9 +278,38 @@ export function GenerateRoadmap() {
data, data,
}); });
setRoadmapTopic(topic); setRoadmapTopic(topic);
setGeneratedRoadmap(data); setGeneratedRoadmapContent(data);
}; };
const handleSvgClick = useCallback(
(e: MouseEvent<HTMLDivElement, globalThis.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(() => { useEffect(() => {
loadAIRoadmapLimit().finally(() => {}); loadAIRoadmapLimit().finally(() => {});
}, []); }, []);
@ -272,120 +341,134 @@ export function GenerateRoadmap() {
const canGenerateMore = roadmapLimitUsed < roadmapLimit; const canGenerateMore = roadmapLimitUsed < roadmapLimit;
return ( return (
<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"> {selectedTopic && currentRoadmap && !isLoading && (
{isLoading && ( <RoadmapTopicDetail
<span className="flex items-center gap-2 rounded-full bg-black px-3 py-1 text-white"> nodeId={selectedTopic.nodeId}
<Spinner isDualRing={false} innerFill={'white'} /> nodeType={selectedTopic.nodeType}
Generating roadmap .. nodeTitle={selectedTopic.nodeTitle}
</span> parentTitle={selectedTopic.parentTitle}
)} onClose={() => setSelectedTopic(null)}
{!isLoading && ( roadmapId={currentRoadmap?.id || ''}
<div className="flex max-w-[600px] flex-grow flex-col items-center px-5"> />
<div className="mt-2 flex w-full items-center justify-between text-sm"> )}
<span className="text-gray-800">
<span <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="flex max-w-[600px] flex-grow flex-col items-center px-5">
<div className="mt-2 flex w-full items-center justify-between text-sm">
<span className="text-gray-800">
<span
className={cn(
'inline-block w-[65px] rounded-md border px-0.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
{!isLoggedIn() && (
<>
{' '}
<button
className="font-medium text-black underline underline-offset-2"
onClick={showLoginPopup}
>
Login to increase your limit
</button>
</>
)}
</span>
</div>
<form
onSubmit={handleSubmit}
className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center"
>
<input
type="text"
autoFocus
placeholder="e.g. Try searching for Ansible or DevOps"
className="flex-grow rounded-md border border-gray-400 px-3 py-2 transition-colors focus:border-black focus:outline-none"
value={roadmapTopic}
onInput={(e) =>
setRoadmapTopic((e.target as HTMLInputElement).value)
}
/>
<button
type={'submit'}
className={cn( className={cn(
'inline-block w-[65px] rounded-md border px-0.5 text-center text-sm tabular-nums text-gray-800', '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',
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
!roadmapLimit,
},
)} )}
disabled={
!roadmapLimit ||
!roadmapTopic ||
roadmapLimitUsed >= roadmapLimit ||
roadmapTopic === currentRoadmap?.topic
}
> >
{roadmapLimitUsed} of {roadmapLimit} {roadmapLimit > 0 && canGenerateMore && (
</span>{' '} <>
roadmaps generated <Wand size={20} />
{!isLoggedIn() && ( Generate
<> </>
{' '} )}
<button
className="font-medium text-black underline underline-offset-2" {roadmapLimit === 0 && <span>Please wait..</span>}
onClick={showLoginPopup}
> {roadmapLimit > 0 && !canGenerateMore && (
Login to increase your limit <span className="flex items-center text-sm">
</button> <Ban size={15} className="mr-2" />
</> Limit reached
)} </span>
</span> )}
</div> </button>
<form </form>
onSubmit={handleSubmit} <div className="flex w-full items-center justify-between gap-2">
className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center" <div className="flex items-center justify-between gap-2">
> <button
<input 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"
type="text" onClick={downloadGeneratedRoadmapContent}
autoFocus >
placeholder="e.g. Try searching for Ansible or DevOps" <Download size={15} />
className="flex-grow rounded-md border border-gray-400 px-3 py-2 transition-colors focus:border-black focus:outline-none" <span className="hidden sm:inline">Download</span>
value={roadmapTopic} </button>
onInput={(e) => {roadmapId && (
setRoadmapTopic((e.target as HTMLInputElement).value) <ShareRoadmapButton
} description={`Check out ${roadmapTopic} roadmap I generated on roadmap.sh`}
/> pageUrl={pageUrl}
<button />
type={'submit'} )}
className={cn( </div>
'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',
)}
disabled={
!roadmapLimit ||
!roadmapTopic ||
roadmapLimitUsed >= roadmapLimit ||
roadmapTopic === currentRoadmap?.topic
}
>
{roadmapLimit > 0 && canGenerateMore && (
<>
<Wand size={20} />
Generate
</>
)}
{roadmapLimit === 0 && <span>Please wait..</span>}
{roadmapLimit > 0 && !canGenerateMore && (
<span className="flex items-center text-sm">
<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 <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" 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={downloadGeneratedRoadmap} onClick={editGeneratedRoadmapContent}
disabled={isLoading}
> >
<Download size={15} /> <PenSquare size={15} />
<span className="hidden sm:inline">Download</span> Edit in Editor
</button> </button>
{roadmapId && (
<ShareRoadmapButton
description={`Check out ${roadmapTopic} roadmap I generated on roadmap.sh`}
pageUrl={pageUrl}
/>
)}
</div> </div>
<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={editGeneratedRoadmap}
disabled={isLoading}
>
<PenSquare size={15} />
Edit in Editor
</button>
</div> </div>
</div> )}
)} </div>
</div> <div
<div ref={roadmapContainerRef}
ref={roadmapContainerRef} id="roadmap-container"
id="roadmap-container" onClick={handleSvgClick}
className="pointer-events-none relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]" className="relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
/> />
</section> </section>
</>
); );
} }

@ -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<HTMLDivElement>(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 (
<div className={'relative z-50'}>
<div
ref={topicRef}
tabIndex={0}
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
>
{isLoading && (
<div className="flex w-full justify-center">
<Spinner
outerFill="#d1d5db"
className="h-6 w-6 sm:h-12 sm:w-12"
innerFill="#2563eb"
/>
</div>
)}
{!isLoading && !error && (
<>
<div className="mb-2">
<button
type="button"
id="close-topic"
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={onClose}
>
<X className="h-5 w-5" />
</button>
</div>
{hasContent ? (
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
<div
id="topic-content"
dangerouslySetInnerHTML={{ __html: topicHtml }}
/>
</div>
) : (
<div className="flex h-[calc(100%-38px)] flex-col items-center justify-center">
<FileText className="h-16 w-16 text-gray-300" />
<p className="mt-2 text-lg font-medium text-gray-500">
Empty Content
</p>
</div>
)}
</>
)}
{/* Error */}
{!isLoading && error && (
<>
<button
type="button"
id="close-topic"
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={onClose}
>
<X className="h-5 w-5" />
</button>
<div className="flex h-full flex-col items-center justify-center">
<Ban className="h-16 w-16 text-red-500" />
<p className="mt-2 text-lg font-medium text-red-500">{error}</p>
</div>
</>
)}
</div>
<div className="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div>
</div>
);
}

@ -41,3 +41,33 @@ export async function readAIRoadmapStream(
onStreamEnd?.(result); onStreamEnd?.(result);
reader.releaseLock(); reader.releaseLock();
} }
export async function readAIRoadmapContentStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{
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();
}

@ -8,6 +8,27 @@ export function markdownToHtml(markdown: string, isInline = true): string {
linkify: true, 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) { if (isInline) {
return md.renderInline(markdown); return md.renderInline(markdown);
} else { } else {

Loading…
Cancel
Save