mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-30 12:40:03 +02:00
Refactor AI roadmap generator (#5300)
* fix: roadmap refetching * fix: remove current roadmap * feat: explore ai roadmaps * feat: generate roadmap content * fix: roadmap topic details * fix: make roadmap link * feat: add visit cookie * chore: update naming * Update UI for roadmap search * Update * Update * UI updates * fix: expire visit cookie in 1 hour * chore: limit roadmap topic content generation * Add alert on generate roadmap * UI for search * Refactor nodesg * Refactor * Load roadmap on click * Refactor UI for ai * Allow overriding with own API key * Allow overriding keys * Add configuration for open ai key * Add open ai saving * Fix responsiveness issues * Fix responsiveness issues --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
149
src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx
Normal file
149
src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { Eye, Loader2, RefreshCcw } from 'lucide-react';
|
||||
import { AIRoadmapAlert } from '../GenerateRoadmap/AIRoadmapAlert.tsx';
|
||||
|
||||
export interface AIRoadmapDocument {
|
||||
_id?: string;
|
||||
term: string;
|
||||
title: string;
|
||||
data: string;
|
||||
viewCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
type ExploreRoadmapsResponse = {
|
||||
data: AIRoadmapDocument[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
currPage: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export function ExploreAIRoadmap() {
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [roadmaps, setRoadmaps] = useState<AIRoadmapDocument[]>([]);
|
||||
const [currPage, setCurrPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
const loadAIRoadmaps = useCallback(
|
||||
async (currPage: number) => {
|
||||
const { response, error } = await httpGet<ExploreRoadmapsResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-ai-roadmaps`,
|
||||
{
|
||||
currPage,
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
const newRoadmaps = [...roadmaps, ...response.data];
|
||||
if (
|
||||
JSON.stringify(roadmaps) === JSON.stringify(response.data) ||
|
||||
JSON.stringify(roadmaps) === JSON.stringify(newRoadmaps)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRoadmaps(newRoadmaps);
|
||||
setCurrPage(response.currPage);
|
||||
setTotalPages(response.totalPages);
|
||||
},
|
||||
[currPage, roadmaps],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadAIRoadmaps(currPage).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hasMorePages = currPage < totalPages;
|
||||
|
||||
return (
|
||||
<section className="container mx-auto py-3 sm:py-6">
|
||||
<div className="mb-6">
|
||||
<AIRoadmapAlert isListing />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{new Array(21).fill(0).map((_, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="h-[75px] animate-pulse rounded-md border bg-gray-100"
|
||||
></li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div>
|
||||
{roadmaps?.length === 0 ? (
|
||||
<div className="text-center text-gray-800">No roadmaps found</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{roadmaps.map((roadmap) => {
|
||||
const roadmapLink = `/ai?id=${roadmap._id}`;
|
||||
return (
|
||||
<a
|
||||
key={roadmap._id}
|
||||
href={roadmapLink}
|
||||
className="flex flex-col rounded-md border transition-colors hover:bg-gray-100"
|
||||
target={'_blank'}
|
||||
>
|
||||
<h2 className="flex-grow px-2.5 py-2.5 text-base font-medium leading-tight">
|
||||
{roadmap.title}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<Eye size={15} className="inline-block" />
|
||||
{Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
}).format(roadmap.viewCount)}{' '}
|
||||
views
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
{getRelativeTimeString(String(roadmap?.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hasMorePages && (
|
||||
<div className="my-5 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsLoadingMore(true);
|
||||
loadAIRoadmaps(currPage + 1).finally(() => {
|
||||
setIsLoadingMore(false);
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-black px-3 py-1.5 text-sm font-medium text-white shadow-xl transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin stroke-[2.5]" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4 stroke-[2.5]" />
|
||||
)}
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
53
src/components/GenerateRoadmap/AIRoadmapAlert.tsx
Normal file
53
src/components/GenerateRoadmap/AIRoadmapAlert.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { BadgeCheck, Telescope, Wand } from 'lucide-react';
|
||||
|
||||
type AIRoadmapAlertProps = {
|
||||
isListing?: boolean;
|
||||
};
|
||||
|
||||
export function AIRoadmapAlert(props: AIRoadmapAlertProps) {
|
||||
const { isListing = false } = props;
|
||||
|
||||
return (
|
||||
<div className="mb-3 w-full rounded-xl bg-yellow-100 px-4 py-3 text-yellow-800">
|
||||
<h2 className="flex items-center text-base font-semibold text-yellow-800 sm:text-lg">
|
||||
AI Generated Roadmap{isListing ? 's' : ''}{' '}
|
||||
<span className="ml-1.5 rounded-md border border-yellow-500 bg-yellow-200 px-1.5 text-xs uppercase tracking-wide text-yellow-800">
|
||||
Beta
|
||||
</span>
|
||||
</h2>
|
||||
<p className="mb-2 mt-1">
|
||||
{isListing
|
||||
? 'These are AI generated roadmaps and are not verified by'
|
||||
: 'This is an AI generated roadmap and is not verified by'}{' '}
|
||||
<span className={'font-semibold'}>roadmap.sh</span>. We are currently in
|
||||
beta and working hard to improve the quality of the generated roadmaps.
|
||||
</p>
|
||||
<p className="mb-1.5 mt-2 flex flex-col gap-2 text-sm sm:flex-row">
|
||||
{isListing ? (
|
||||
<a
|
||||
href="/ai"
|
||||
className="flex items-center gap-1.5 rounded-md border border-yellow-600 px-2 py-1 text-yellow-700 transition-colors hover:bg-yellow-300 hover:text-yellow-800"
|
||||
>
|
||||
<Wand size={15} />
|
||||
Create your own Roadmap with AI
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href="/ai/explore"
|
||||
className="flex items-center gap-1.5 rounded-md border border-yellow-600 px-2 py-1 text-yellow-700 transition-colors hover:bg-yellow-300 hover:text-yellow-800"
|
||||
>
|
||||
<Telescope size={15} />
|
||||
Explore other AI Roadmaps
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="flex items-center gap-1.5 rounded-md border border-yellow-600 bg-yellow-200 px-2 py-1 text-yellow-800 transition-colors hover:bg-yellow-300"
|
||||
>
|
||||
<BadgeCheck size={15} />
|
||||
Visit Official Roadmaps
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,14 +1,26 @@
|
||||
import { type FormEvent, useEffect, useRef, useState } from 'react';
|
||||
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 { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import {
|
||||
getOpenAIKey,
|
||||
isLoggedIn,
|
||||
removeAuthToken,
|
||||
visitAIRoadmap,
|
||||
} from '../../lib/jwt';
|
||||
import { RoadmapSearch } from './RoadmapSearch.tsx';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { Ban, Download, PenSquare, Wand } from 'lucide-react';
|
||||
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';
|
||||
@@ -20,9 +32,55 @@ 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';
|
||||
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
|
||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||
|
||||
export type GetAIRoadmapLimitResponse = {
|
||||
used: number;
|
||||
limit: number;
|
||||
topicUsed: number;
|
||||
topicLimit: number;
|
||||
};
|
||||
|
||||
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;
|
||||
term: string;
|
||||
title: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export function GenerateRoadmap() {
|
||||
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -31,11 +89,21 @@ export function GenerateRoadmap() {
|
||||
|
||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [roadmapTopic, setRoadmapTopic] = useState('');
|
||||
const [generatedRoadmap, setGeneratedRoadmap] = useState('');
|
||||
const [roadmapTerm, setRoadmapTerm] = useState('');
|
||||
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
|
||||
const [currentRoadmap, setCurrentRoadmap] =
|
||||
useState<GetAIRoadmapResponse | null>(null);
|
||||
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 = getOpenAIKey();
|
||||
|
||||
const renderRoadmap = async (roadmap: string) => {
|
||||
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
|
||||
@@ -45,12 +113,7 @@ export function GenerateRoadmap() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!roadmapTopic) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadTermRoadmap = async (term: string) => {
|
||||
setIsLoading(true);
|
||||
setHasSubmitted(true);
|
||||
|
||||
@@ -61,6 +124,7 @@ export function GenerateRoadmap() {
|
||||
}
|
||||
|
||||
deleteUrlParam('id');
|
||||
setCurrentRoadmap(null);
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
|
||||
@@ -70,7 +134,7 @@ export function GenerateRoadmap() {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ topic: roadmapTopic }),
|
||||
body: JSON.stringify({ term }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -104,13 +168,19 @@ export function GenerateRoadmap() {
|
||||
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
|
||||
setUrlParams({ id: roadmapId });
|
||||
result = result.replace(ROADMAP_ID_REGEX, '');
|
||||
setCurrentRoadmap({
|
||||
id: roadmapId,
|
||||
term: roadmapTerm,
|
||||
title: term,
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
|
||||
await renderRoadmap(result);
|
||||
},
|
||||
onStreamEnd: async (result) => {
|
||||
result = result.replace(ROADMAP_ID_REGEX, '');
|
||||
setGeneratedRoadmap(result);
|
||||
setGeneratedRoadmapContent(result);
|
||||
loadAIRoadmapLimit().finally(() => {});
|
||||
},
|
||||
});
|
||||
@@ -118,7 +188,20 @@ export function GenerateRoadmap() {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const editGeneratedRoadmap = async () => {
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!roadmapTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (roadmapTerm === currentRoadmap?.topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadTermRoadmap(roadmapTerm).finally(() => null);
|
||||
};
|
||||
|
||||
const saveAIRoadmap = async () => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
@@ -126,38 +209,44 @@ 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;
|
||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-edit-ai-generated-roadmap`, {
|
||||
title: roadmapTopic,
|
||||
nodes: nodes.map((node) => ({
|
||||
...node,
|
||||
}>(
|
||||
`${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,
|
||||
// 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,
|
||||
},
|
||||
})),
|
||||
edges,
|
||||
});
|
||||
style: {
|
||||
...node.style,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
},
|
||||
})),
|
||||
edges,
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${response.roadmapId}`;
|
||||
setIsLoading(false);
|
||||
pageProgressMessage.set('');
|
||||
return response.roadmapId;
|
||||
};
|
||||
|
||||
const downloadGeneratedRoadmap = async () => {
|
||||
const downloadGeneratedRoadmapContent = async () => {
|
||||
pageProgressMessage.set('Downloading Roadmap');
|
||||
|
||||
const node = document.getElementById('roadmap-container');
|
||||
@@ -167,7 +256,7 @@ export function GenerateRoadmap() {
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadGeneratedRoadmapImage(roadmapTopic, node);
|
||||
await downloadGeneratedRoadmapImage(roadmapTerm, node);
|
||||
pageProgressMessage.set('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -176,19 +265,20 @@ export function GenerateRoadmap() {
|
||||
};
|
||||
|
||||
const loadAIRoadmapLimit = async () => {
|
||||
const { response, error } = await httpGet<{
|
||||
limit: number;
|
||||
used: number;
|
||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap-limit`);
|
||||
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 } = response;
|
||||
const { limit, used, topicLimit, topicUsed } = response;
|
||||
setRoadmapLimit(limit);
|
||||
setRoadmapLimitUsed(used);
|
||||
setRoadmapTopicLimit(topicLimit);
|
||||
setRoadmapTopicLimitUsed(topicUsed);
|
||||
};
|
||||
|
||||
const loadAIRoadmap = async (roadmapId: string) => {
|
||||
@@ -205,19 +295,65 @@ export function GenerateRoadmap() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { topic, data } = response;
|
||||
const { term, title, data } = response;
|
||||
await renderRoadmap(data);
|
||||
|
||||
setRoadmapTopic(topic);
|
||||
setGeneratedRoadmap(data);
|
||||
setCurrentRoadmap({
|
||||
id: roadmapId,
|
||||
title: title,
|
||||
term: term,
|
||||
data,
|
||||
});
|
||||
|
||||
setRoadmapTerm(title);
|
||||
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 (!roadmapId) {
|
||||
if (!roadmapId || roadmapId === currentRoadmap?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,137 +361,230 @@ export function GenerateRoadmap() {
|
||||
loadAIRoadmap(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, [roadmapId]);
|
||||
}, [roadmapId, currentRoadmap]);
|
||||
|
||||
if (!hasSubmitted) {
|
||||
return (
|
||||
<RoadmapSearch
|
||||
roadmapTopic={roadmapTopic}
|
||||
setRoadmapTopic={setRoadmapTopic}
|
||||
roadmapTerm={roadmapTerm}
|
||||
setRoadmapTerm={setRoadmapTerm}
|
||||
handleSubmit={handleSubmit}
|
||||
limit={roadmapLimit}
|
||||
limitUsed={roadmapLimitUsed}
|
||||
loadAIRoadmapLimit={loadAIRoadmapLimit}
|
||||
onLoadTerm={(term: string) => {
|
||||
setRoadmapTerm(term);
|
||||
loadTermRoadmap(term).finally(() => {});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`;
|
||||
const canGenerateMore = roadmapLimitUsed < roadmapLimit;
|
||||
const isLoggedInUser = isLoggedIn();
|
||||
|
||||
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">
|
||||
{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>
|
||||
</>
|
||||
<>
|
||||
{isConfiguring && (
|
||||
<OpenAISettings
|
||||
onClose={() => {
|
||||
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-center">
|
||||
<AIRoadmapAlert />
|
||||
<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.
|
||||
</span>
|
||||
{!isLoggedInUser && (
|
||||
<button
|
||||
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
||||
onClick={showLoginPopup}
|
||||
>
|
||||
Generate more by{' '}
|
||||
<span className="font-semibold">
|
||||
signing up (free, takes 2s)
|
||||
</span>{' '}
|
||||
or <span className="font-semibold">logging in</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="my-3 flex w-full flex-col sm:flex-row sm:items-center sm:justify-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="e.g. Ansible"
|
||||
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(
|
||||
'flex min-w-[127px] flex-shrink-0 items-center gap-2 rounded-md bg-black px-4 py-2 text-white justify-center',
|
||||
{
|
||||
'cursor-not-allowed opacity-50':
|
||||
!roadmapLimit ||
|
||||
!roadmapTopic ||
|
||||
roadmapLimitUsed >= roadmapLimit,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{roadmapLimit > 0 && canGenerateMore && (
|
||||
<>
|
||||
<Wand size={20} />
|
||||
Generate
|
||||
</>
|
||||
{isLoggedInUser && !openAPIKey && (
|
||||
<button
|
||||
onClick={() => setIsConfiguring(true)}
|
||||
className="text-left rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
||||
>
|
||||
By-pass all limits by{' '}
|
||||
<span className="font-semibold">
|
||||
adding your own OpenAI API key
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{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
|
||||
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={downloadGeneratedRoadmap}
|
||||
>
|
||||
<Download size={15} />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</button>
|
||||
{roadmapId && (
|
||||
<ShareRoadmapButton
|
||||
description={`Check out ${roadmapTopic} roadmap I generated on roadmap.sh`}
|
||||
pageUrl={pageUrl}
|
||||
/>
|
||||
{isLoggedInUser && 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>
|
||||
<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}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center"
|
||||
>
|
||||
<PenSquare size={15} />
|
||||
Edit in Editor
|
||||
</button>
|
||||
<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={roadmapTerm}
|
||||
onInput={(e) =>
|
||||
setRoadmapTerm((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
<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',
|
||||
)}
|
||||
disabled={
|
||||
!roadmapLimit ||
|
||||
!roadmapTerm ||
|
||||
roadmapLimitUsed >= roadmapLimit ||
|
||||
roadmapTerm === currentRoadmap?.term
|
||||
}
|
||||
>
|
||||
{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
|
||||
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>
|
||||
<div
|
||||
ref={roadmapContainerRef}
|
||||
id="roadmap-container"
|
||||
className="relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={roadmapContainerRef}
|
||||
id="roadmap-container"
|
||||
onClick={handleNodeClick}
|
||||
className="relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
168
src/components/GenerateRoadmap/OpenAISettings.tsx
Normal file
168
src/components/GenerateRoadmap/OpenAISettings.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Modal } from '../Modal.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
deleteOpenAIKey,
|
||||
getOpenAIKey,
|
||||
saveOpenAIKey,
|
||||
} from '../../lib/jwt.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { CloseIcon } from '../ReactIcons/CloseIcon.tsx';
|
||||
import { useToast } from '../../hooks/use-toast.ts';
|
||||
import { httpPost } from '../../lib/http.ts';
|
||||
|
||||
type OpenAISettingsProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function OpenAISettings(props: OpenAISettingsProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const [defaultOpenAIKey, setDefaultOpenAIKey] = useState('');
|
||||
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [openaiApiKey, setOpenaiApiKey] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const apiKey = getOpenAIKey();
|
||||
setOpenaiApiKey(apiKey || '');
|
||||
setDefaultOpenAIKey(apiKey || '');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<div className="p-5">
|
||||
<h2 className="text-xl font-medium text-gray-800">OpenAI Settings</h2>
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-700">
|
||||
AI Roadmap generator uses OpenAI's GPT-4 model to generate roadmaps.
|
||||
</p>
|
||||
|
||||
<p className="mt-2">
|
||||
<a
|
||||
className="font-semibold underline underline-offset-2"
|
||||
href={'https://platform.openai.com/signup'}
|
||||
target="_blank"
|
||||
>
|
||||
Create an account on OpenAI
|
||||
</a>{' '}
|
||||
and enter your API key below to enable the AI Roadmap generator
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="mt-4"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setHasError(false);
|
||||
|
||||
const normalizedKey = openaiApiKey.trim();
|
||||
if (!normalizedKey) {
|
||||
deleteOpenAIKey();
|
||||
toast.success('OpenAI API key removed');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizedKey.startsWith('sk-')) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-validate-openai-key`,
|
||||
{
|
||||
key: normalizedKey,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setHasError(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the API key to cookies
|
||||
saveOpenAIKey(normalizedKey);
|
||||
toast.success('OpenAI API key saved');
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="openai-api-key"
|
||||
id="openai-api-key"
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-800 transition-colors focus:border-black focus:outline-none',
|
||||
{
|
||||
'border-red-500 bg-red-100 focus:border-red-500': hasError,
|
||||
},
|
||||
)}
|
||||
placeholder="Enter your OpenAI API key"
|
||||
value={openaiApiKey}
|
||||
onChange={(e) => {
|
||||
setHasError(false);
|
||||
setOpenaiApiKey((e.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
|
||||
{openaiApiKey && (
|
||||
<button
|
||||
type={'button'}
|
||||
onClick={() => {
|
||||
setOpenaiApiKey('');
|
||||
}}
|
||||
className="absolute right-2 top-1/2 flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center rounded-full bg-gray-400 text-white hover:bg-gray-600"
|
||||
>
|
||||
<CloseIcon className="h-[13px] w-[13px] stroke-[3.5]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasError && (
|
||||
<p className="mt-2 text-sm text-red-500">
|
||||
Please enter a valid OpenAI API key
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
className={
|
||||
'mt-2 w-full rounded-md bg-gray-700 px-4 py-2 text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
{!isLoading && 'Save'}
|
||||
{isLoading && 'Validating ..'}
|
||||
</button>
|
||||
{!defaultOpenAIKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
className="mt-1 w-full rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-black hover:bg-red-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{defaultOpenAIKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
deleteOpenAIKey();
|
||||
onClose();
|
||||
toast.success('OpenAI API key removed');
|
||||
}}
|
||||
className="mt-1 w-full rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-black hover:bg-red-700"
|
||||
>
|
||||
Reset to Default Key
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@@ -1,30 +1,55 @@
|
||||
import { Ban, Wand } from 'lucide-react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Ban,
|
||||
CircleFadingPlus,
|
||||
Cog,
|
||||
Telescope,
|
||||
Wand,
|
||||
} from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { getOpenAIKey, isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { useState } from 'react';
|
||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||
|
||||
type RoadmapSearchProps = {
|
||||
roadmapTopic: string;
|
||||
setRoadmapTopic: (topic: string) => void;
|
||||
roadmapTerm: string;
|
||||
setRoadmapTerm: (topic: string) => void;
|
||||
handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
loadAIRoadmapLimit: () => void;
|
||||
onLoadTerm: (topic: string) => void;
|
||||
limit: number;
|
||||
limitUsed: number;
|
||||
};
|
||||
|
||||
export function RoadmapSearch(props: RoadmapSearchProps) {
|
||||
const {
|
||||
roadmapTopic,
|
||||
setRoadmapTopic,
|
||||
roadmapTerm,
|
||||
setRoadmapTerm,
|
||||
handleSubmit,
|
||||
limit = 0,
|
||||
limitUsed = 0,
|
||||
onLoadTerm,
|
||||
loadAIRoadmapLimit,
|
||||
} = props;
|
||||
|
||||
const canGenerateMore = limitUsed < limit;
|
||||
const [isConfiguring, setIsConfiguring] = useState(false);
|
||||
const openAPIKey = getOpenAIKey();
|
||||
|
||||
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow flex-col items-center justify-center px-4 py-6 sm:px-6">
|
||||
{isConfiguring && (
|
||||
<OpenAISettings
|
||||
onClose={() => {
|
||||
setIsConfiguring(false);
|
||||
loadAIRoadmapLimit();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-0 text-center sm:gap-2">
|
||||
<h1 className="relative text-2xl font-medium sm:text-3xl">
|
||||
<span className="hidden sm:inline">Generate roadmaps with AI</span>
|
||||
@@ -39,61 +64,77 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
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"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="e.g. Ansible"
|
||||
className="w-full rounded-md border border-gray-400 px-3 py-2.5 transition-colors focus:border-black focus:outline-none"
|
||||
value={roadmapTopic}
|
||||
onInput={(e) => setRoadmapTopic((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-w-[143px] flex-shrink-0 items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-white',
|
||||
{
|
||||
'cursor-not-allowed opacity-50':
|
||||
!limit || !roadmapTopic || limitUsed >= limit,
|
||||
},
|
||||
)}
|
||||
<div className="my-3 flex w-full max-w-[600px] flex-col items-center gap-3 sm:my-5">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
if (limit > 0 && canGenerateMore) {
|
||||
handleSubmit(e);
|
||||
} else {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="flex w-full flex-col gap-2 sm:flex-row"
|
||||
>
|
||||
{limit > 0 && canGenerateMore && (
|
||||
<>
|
||||
<Wand size={20} />
|
||||
Generate
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Enter a topic to generate a roadmap for"
|
||||
className="w-full rounded-md border border-gray-400 px-3 py-2.5 transition-colors focus:border-black focus:outline-none"
|
||||
value={roadmapTerm}
|
||||
onInput={(e) =>
|
||||
setRoadmapTerm((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-w-[154px] 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={!limit || !roadmapTerm || limitUsed >= limit}
|
||||
>
|
||||
{(!limit || canGenerateMore) && (
|
||||
<>
|
||||
<Wand size={20} />
|
||||
Generate
|
||||
</>
|
||||
)}
|
||||
|
||||
{limit === 0 && (
|
||||
<>
|
||||
<span>Please wait..</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{limit > 0 && !canGenerateMore && (
|
||||
<span className="flex items-center text-base sm:text-sm">
|
||||
<Ban size={15} className="mr-2" />
|
||||
Limit reached
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<div className="mb-36">
|
||||
{limit > 0 && !canGenerateMore && (
|
||||
<span className="flex items-center text-base">
|
||||
<Ban size={15} className="mr-2" />
|
||||
Limit reached
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex flex-row items-center justify-center gap-2 flex-wrap">
|
||||
{randomTerms.map((term) => (
|
||||
<button
|
||||
key={term}
|
||||
disabled={!limit || !canGenerateMore}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onLoadTerm(term);
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-sm transition-colors hover:border-black hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{term} <ArrowUpRight size={17} />
|
||||
</button>
|
||||
))}
|
||||
<a
|
||||
href="/ai/explore"
|
||||
className="flex items-center gap-1.5 rounded-full border border-black bg-gray-700 px-2 py-0.5 text-sm text-white transition-colors hover:border-black hover:bg-black"
|
||||
>
|
||||
Explore AI Roadmaps <Telescope size={17} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 flex flex-col items-center gap-4">
|
||||
<p className="text-gray-500">
|
||||
<span className="inline sm:hidden">Generated </span>
|
||||
<span className="hidden sm:inline">You have generated </span>
|
||||
You have generated{' '}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-[65px] rounded-md border px-0.5 text-center text-sm tabular-nums text-gray-800',
|
||||
'inline-block min-w-[50px] 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':
|
||||
!limit,
|
||||
@@ -103,16 +144,42 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
||||
{limitUsed} of {limit}
|
||||
</span>{' '}
|
||||
roadmaps.
|
||||
{!isLoggedIn && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
className="font-semibold text-black underline underline-offset-2"
|
||||
onClick={showLoginPopup}
|
||||
>
|
||||
Log in to increase your limit
|
||||
</button>
|
||||
</>
|
||||
</p>
|
||||
<p className="flex min-h-[26px] items-center text-sm">
|
||||
{limit > 0 && !isLoggedIn() && (
|
||||
<button
|
||||
onClick={showLoginPopup}
|
||||
className="rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
||||
>
|
||||
Generate more by{' '}
|
||||
<span className="font-semibold">
|
||||
signing up (free and takes 2 seconds)
|
||||
</span>{' '}
|
||||
or <span className="font-semibold">logging in</span>
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
<p className="-mt-[45px] flex min-h-[26px] items-center text-sm">
|
||||
{limit > 0 && isLoggedIn() && !openAPIKey && (
|
||||
<button
|
||||
onClick={() => setIsConfiguring(true)}
|
||||
className="rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
||||
>
|
||||
By-pass all limits by{' '}
|
||||
<span className="font-semibold">
|
||||
adding your own OpenAI API key
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{limit > 0 && isLoggedIn() && 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>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
241
src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
Normal file
241
src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
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, Cog, FileText, X } from 'lucide-react';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import type { RoadmapNodeDetails } from './GenerateRoadmap';
|
||||
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import { readAIRoadmapContentStream } from '../../helper/read-stream';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||
|
||||
type RoadmapTopicDetailProps = RoadmapNodeDetails & {
|
||||
onClose?: () => void;
|
||||
roadmapId: string;
|
||||
topicLimitUsed: number;
|
||||
topicLimit: number;
|
||||
onTopicContentGenerateComplete?: () => void;
|
||||
onConfigureOpenAI?: () => void;
|
||||
};
|
||||
|
||||
export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
|
||||
const {
|
||||
onClose,
|
||||
roadmapId,
|
||||
nodeTitle,
|
||||
parentTitle,
|
||||
topicLimit,
|
||||
topicLimitUsed,
|
||||
onTopicContentGenerateComplete,
|
||||
onConfigureOpenAI,
|
||||
} = 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('');
|
||||
//
|
||||
// if (topicLimitUsed >= topicLimit) {
|
||||
// setError('Maximum limit reached');
|
||||
// setIsLoading(false);
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (!roadmapId || !nodeTitle) {
|
||||
setIsLoading(false);
|
||||
setError('Invalid roadmap id or node title');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-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));
|
||||
},
|
||||
});
|
||||
onTopicContentGenerateComplete?.();
|
||||
};
|
||||
|
||||
// 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;
|
||||
const openAIKey = getOpenAIKey();
|
||||
|
||||
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"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row">
|
||||
<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':
|
||||
!topicLimit,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{topicLimitUsed} of {topicLimit}
|
||||
</span>{' '}
|
||||
topics generated
|
||||
</span>
|
||||
{!isLoggedIn() && (
|
||||
<button
|
||||
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
||||
onClick={showLoginPopup}
|
||||
>
|
||||
Generate more by <span className="font-semibold">logging in</span>
|
||||
</button>
|
||||
)}
|
||||
{isLoggedIn() && !openAIKey && (
|
||||
<button
|
||||
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
||||
onClick={onConfigureOpenAI}
|
||||
>
|
||||
By-pass all limits by{' '}
|
||||
<span className="font-semibold">adding your own OpenAI Key</span>
|
||||
</button>
|
||||
)}
|
||||
{isLoggedIn() && openAIKey && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
||||
onClick={onConfigureOpenAI}
|
||||
>
|
||||
<Cog className="-mt-0.5 inline-block h-4 w-4" />
|
||||
Configure OpenAI Key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mt-6 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);
|
||||
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();
|
||||
}
|
||||
|
@@ -26,5 +26,9 @@ export function getRelativeTimeString(date: string): string {
|
||||
relativeTime = rtf.format(-diffInDays, 'day');
|
||||
}
|
||||
|
||||
if (relativeTime === 'this minute') {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
return relativeTime;
|
||||
}
|
||||
|
@@ -48,3 +48,39 @@ export function removeAuthToken() {
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
}
|
||||
|
||||
export function visitAIRoadmap(roadmapId: string) {
|
||||
const isAlreadyVisited = Number(Cookies.get(`crv-${roadmapId}`) || 0) === 1;
|
||||
if (isAlreadyVisited) {
|
||||
return;
|
||||
}
|
||||
|
||||
Cookies.set(`crv-${roadmapId}`, '1', {
|
||||
path: '/',
|
||||
expires: 1 / 24, // 1 hour
|
||||
sameSite: 'lax',
|
||||
secure: !import.meta.env.DEV,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOpenAIKey() {
|
||||
Cookies.remove('oak', {
|
||||
path: '/',
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
}
|
||||
|
||||
export function saveOpenAIKey(apiKey: string) {
|
||||
Cookies.set('oak', apiKey, {
|
||||
path: '/',
|
||||
expires: 365,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
}
|
||||
|
||||
export function getOpenAIKey() {
|
||||
return Cookies.get('oak');
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
10
src/pages/ai/explore.astro
Normal file
10
src/pages/ai/explore.astro
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import LoginPopup from '../../components/AuthenticationFlow/LoginPopup.astro';
|
||||
import { ExploreAIRoadmap } from '../../components/ExploreAIRoadmap/ExploreAIRoadmap';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
<AccountLayout title='Explore Roadmap AI'>
|
||||
<ExploreAIRoadmap client:load />
|
||||
<LoginPopup />
|
||||
</AccountLayout>
|
Reference in New Issue
Block a user