mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-31 04:59:50 +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 './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';
|
||||||
import { renderFlowJSON } from '../../../editor/renderer/renderer';
|
import { renderFlowJSON } from '../../../editor/renderer/renderer';
|
||||||
import { replaceChildren } from '../../lib/dom';
|
import { replaceChildren } from '../../lib/dom';
|
||||||
import { readAIRoadmapStream } from '../../helper/read-stream';
|
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 { RoadmapSearch } from './RoadmapSearch.tsx';
|
||||||
import { Spinner } from '../ReactIcons/Spinner.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 { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
|
||||||
import { httpGet, httpPost } from '../../lib/http.ts';
|
import { httpGet, httpPost } from '../../lib/http.ts';
|
||||||
import { pageProgressMessage } from '../../stores/page.ts';
|
import { pageProgressMessage } from '../../stores/page.ts';
|
||||||
@@ -20,9 +32,55 @@ 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';
|
||||||
|
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+)@');
|
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() {
|
export function GenerateRoadmap() {
|
||||||
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -31,11 +89,21 @@ 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 [roadmapTerm, setRoadmapTerm] = useState('');
|
||||||
const [generatedRoadmap, setGeneratedRoadmap] = 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 [roadmapLimit, setRoadmapLimit] = useState(0);
|
||||||
const [roadmapLimitUsed, setRoadmapLimitUsed] = 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 renderRoadmap = async (roadmap: string) => {
|
||||||
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
|
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
|
||||||
@@ -45,12 +113,7 @@ export function GenerateRoadmap() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const loadTermRoadmap = async (term: string) => {
|
||||||
e.preventDefault();
|
|
||||||
if (!roadmapTopic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setHasSubmitted(true);
|
setHasSubmitted(true);
|
||||||
|
|
||||||
@@ -61,6 +124,7 @@ export function GenerateRoadmap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteUrlParam('id');
|
deleteUrlParam('id');
|
||||||
|
setCurrentRoadmap(null);
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
|
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
|
||||||
@@ -70,7 +134,7 @@ export function GenerateRoadmap() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
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] || '';
|
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
|
||||||
setUrlParams({ id: roadmapId });
|
setUrlParams({ id: roadmapId });
|
||||||
result = result.replace(ROADMAP_ID_REGEX, '');
|
result = result.replace(ROADMAP_ID_REGEX, '');
|
||||||
|
setCurrentRoadmap({
|
||||||
|
id: roadmapId,
|
||||||
|
term: roadmapTerm,
|
||||||
|
title: term,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await renderRoadmap(result);
|
await renderRoadmap(result);
|
||||||
},
|
},
|
||||||
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(() => {});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -118,7 +188,20 @@ export function GenerateRoadmap() {
|
|||||||
setIsLoading(false);
|
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()) {
|
if (!isLoggedIn()) {
|
||||||
showLoginPopup();
|
showLoginPopup();
|
||||||
return;
|
return;
|
||||||
@@ -126,38 +209,44 @@ 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;
|
||||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-edit-ai-generated-roadmap`, {
|
}>(
|
||||||
title: roadmapTopic,
|
`${import.meta.env.PUBLIC_API_URL}/v1-save-ai-roadmap/${currentRoadmap?.id}`,
|
||||||
nodes: nodes.map((node) => ({
|
{
|
||||||
...node,
|
title: roadmapTerm,
|
||||||
|
nodes: nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
|
||||||
// To reset the width and height of the node
|
// To reset the width and height of the node
|
||||||
// so that it can be calculated based on the content in the editor
|
// so that it can be calculated based on the content in the editor
|
||||||
width: undefined,
|
|
||||||
height: undefined,
|
|
||||||
style: {
|
|
||||||
...node.style,
|
|
||||||
width: undefined,
|
width: undefined,
|
||||||
height: undefined,
|
height: undefined,
|
||||||
},
|
style: {
|
||||||
})),
|
...node.style,
|
||||||
edges,
|
width: undefined,
|
||||||
});
|
height: undefined,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
edges,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (error || !response) {
|
if (error || !response) {
|
||||||
toast.error(error?.message || 'Something went wrong');
|
toast.error(error?.message || 'Something went wrong');
|
||||||
|
pageProgressMessage.set('');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
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');
|
pageProgressMessage.set('Downloading Roadmap');
|
||||||
|
|
||||||
const node = document.getElementById('roadmap-container');
|
const node = document.getElementById('roadmap-container');
|
||||||
@@ -167,7 +256,7 @@ export function GenerateRoadmap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadGeneratedRoadmapImage(roadmapTopic, node);
|
await downloadGeneratedRoadmapImage(roadmapTerm, node);
|
||||||
pageProgressMessage.set('');
|
pageProgressMessage.set('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -176,19 +265,20 @@ export function GenerateRoadmap() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadAIRoadmapLimit = async () => {
|
const loadAIRoadmapLimit = async () => {
|
||||||
const { response, error } = await httpGet<{
|
const { response, error } = await httpGet<GetAIRoadmapLimitResponse>(
|
||||||
limit: number;
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap-limit`,
|
||||||
used: number;
|
);
|
||||||
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap-limit`);
|
|
||||||
|
|
||||||
if (error || !response) {
|
if (error || !response) {
|
||||||
toast.error(error?.message || 'Something went wrong');
|
toast.error(error?.message || 'Something went wrong');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { limit, used } = response;
|
const { limit, used, topicLimit, topicUsed } = response;
|
||||||
setRoadmapLimit(limit);
|
setRoadmapLimit(limit);
|
||||||
setRoadmapLimitUsed(used);
|
setRoadmapLimitUsed(used);
|
||||||
|
setRoadmapTopicLimit(topicLimit);
|
||||||
|
setRoadmapTopicLimitUsed(topicUsed);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadAIRoadmap = async (roadmapId: string) => {
|
const loadAIRoadmap = async (roadmapId: string) => {
|
||||||
@@ -205,19 +295,65 @@ export function GenerateRoadmap() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { topic, data } = response;
|
const { term, title, data } = response;
|
||||||
await renderRoadmap(data);
|
await renderRoadmap(data);
|
||||||
|
|
||||||
setRoadmapTopic(topic);
|
setCurrentRoadmap({
|
||||||
setGeneratedRoadmap(data);
|
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(() => {
|
useEffect(() => {
|
||||||
loadAIRoadmapLimit().finally(() => {});
|
loadAIRoadmapLimit().finally(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roadmapId) {
|
if (!roadmapId || roadmapId === currentRoadmap?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,137 +361,230 @@ export function GenerateRoadmap() {
|
|||||||
loadAIRoadmap(roadmapId).finally(() => {
|
loadAIRoadmap(roadmapId).finally(() => {
|
||||||
pageProgressMessage.set('');
|
pageProgressMessage.set('');
|
||||||
});
|
});
|
||||||
}, [roadmapId]);
|
}, [roadmapId, currentRoadmap]);
|
||||||
|
|
||||||
if (!hasSubmitted) {
|
if (!hasSubmitted) {
|
||||||
return (
|
return (
|
||||||
<RoadmapSearch
|
<RoadmapSearch
|
||||||
roadmapTopic={roadmapTopic}
|
roadmapTerm={roadmapTerm}
|
||||||
setRoadmapTopic={setRoadmapTopic}
|
setRoadmapTerm={setRoadmapTerm}
|
||||||
handleSubmit={handleSubmit}
|
handleSubmit={handleSubmit}
|
||||||
limit={roadmapLimit}
|
limit={roadmapLimit}
|
||||||
limitUsed={roadmapLimitUsed}
|
limitUsed={roadmapLimitUsed}
|
||||||
|
loadAIRoadmapLimit={loadAIRoadmapLimit}
|
||||||
|
onLoadTerm={(term: string) => {
|
||||||
|
setRoadmapTerm(term);
|
||||||
|
loadTermRoadmap(term).finally(() => {});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`;
|
const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`;
|
||||||
const canGenerateMore = roadmapLimitUsed < roadmapLimit;
|
const canGenerateMore = roadmapLimitUsed < roadmapLimit;
|
||||||
|
const isLoggedInUser = isLoggedIn();
|
||||||
|
|
||||||
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">
|
{isConfiguring && (
|
||||||
{isLoading && (
|
<OpenAISettings
|
||||||
<span className="flex items-center gap-2 rounded-full bg-black px-3 py-1 text-white">
|
onClose={() => {
|
||||||
<Spinner isDualRing={false} innerFill={'white'} />
|
setIsConfiguring(false);
|
||||||
Generating roadmap ..
|
loadAIRoadmapLimit().finally(() => null);
|
||||||
</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">
|
{selectedNode && currentRoadmap && !isLoading && (
|
||||||
<span className="text-gray-800">
|
<RoadmapTopicDetail
|
||||||
<span
|
nodeId={selectedNode.nodeId}
|
||||||
className={cn(
|
nodeType={selectedNode.nodeType}
|
||||||
'inline-block w-[65px] rounded-md border px-0.5 text-center text-sm tabular-nums text-gray-800',
|
nodeTitle={selectedNode.nodeTitle}
|
||||||
{
|
parentTitle={selectedNode.parentTitle}
|
||||||
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
|
onConfigureOpenAI={() => {
|
||||||
!roadmapLimit,
|
setSelectedNode(null);
|
||||||
},
|
setIsConfiguring(true);
|
||||||
)}
|
}}
|
||||||
>
|
onClose={() => {
|
||||||
{roadmapLimitUsed} of {roadmapLimit}
|
setSelectedNode(null);
|
||||||
</span>{' '}
|
loadAIRoadmapLimit().finally(() => {});
|
||||||
roadmaps generated
|
}}
|
||||||
{!isLoggedIn() && (
|
roadmapId={currentRoadmap?.id || ''}
|
||||||
<>
|
topicLimit={roadmapTopicLimit}
|
||||||
{' '}
|
topicLimitUsed={roadmapTopicLimitUsed}
|
||||||
<button
|
onTopicContentGenerateComplete={async () => {
|
||||||
className="font-medium text-black underline underline-offset-2"
|
await loadAIRoadmapLimit();
|
||||||
onClick={showLoginPopup}
|
}}
|
||||||
>
|
/>
|
||||||
Login to increase your limit
|
)}
|
||||||
</button>
|
|
||||||
</>
|
<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>
|
{isLoggedInUser && !openAPIKey && (
|
||||||
</div>
|
<button
|
||||||
<form
|
onClick={() => setIsConfiguring(true)}
|
||||||
onSubmit={handleSubmit}
|
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"
|
||||||
className="my-3 flex w-full flex-col sm:flex-row sm:items-center sm:justify-center gap-2"
|
>
|
||||||
>
|
By-pass all limits by{' '}
|
||||||
<input
|
<span className="font-semibold">
|
||||||
type="text"
|
adding your own OpenAI API key
|
||||||
autoFocus
|
</span>
|
||||||
placeholder="e.g. Ansible"
|
</button>
|
||||||
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
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{roadmapLimit === 0 && <span>Please wait..</span>}
|
{isLoggedInUser && openAPIKey && (
|
||||||
|
<button
|
||||||
{roadmapLimit > 0 && !canGenerateMore && (
|
onClick={() => setIsConfiguring(true)}
|
||||||
<span className="flex items-center text-sm">
|
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"
|
||||||
<Ban size={15} className="mr-2" />
|
>
|
||||||
Limit reached
|
<Cog size={15} />
|
||||||
</span>
|
Configure OpenAI key
|
||||||
)}
|
</button>
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<form
|
||||||
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"
|
onSubmit={handleSubmit}
|
||||||
onClick={editGeneratedRoadmap}
|
className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center"
|
||||||
disabled={isLoading}
|
|
||||||
>
|
>
|
||||||
<PenSquare size={15} />
|
<input
|
||||||
Edit in Editor
|
type="text"
|
||||||
</button>
|
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>
|
||||||
</div>
|
<div
|
||||||
<div
|
ref={roadmapContainerRef}
|
||||||
ref={roadmapContainerRef}
|
id="roadmap-container"
|
||||||
id="roadmap-container"
|
onClick={handleNodeClick}
|
||||||
className="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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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 type { FormEvent } from 'react';
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
import { getOpenAIKey, isLoggedIn } from '../../lib/jwt';
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
import { cn } from '../../lib/classname.ts';
|
import { cn } from '../../lib/classname.ts';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||||
|
|
||||||
type RoadmapSearchProps = {
|
type RoadmapSearchProps = {
|
||||||
roadmapTopic: string;
|
roadmapTerm: string;
|
||||||
setRoadmapTopic: (topic: string) => void;
|
setRoadmapTerm: (topic: string) => void;
|
||||||
handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||||
|
loadAIRoadmapLimit: () => void;
|
||||||
|
onLoadTerm: (topic: string) => void;
|
||||||
limit: number;
|
limit: number;
|
||||||
limitUsed: number;
|
limitUsed: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RoadmapSearch(props: RoadmapSearchProps) {
|
export function RoadmapSearch(props: RoadmapSearchProps) {
|
||||||
const {
|
const {
|
||||||
roadmapTopic,
|
roadmapTerm,
|
||||||
setRoadmapTopic,
|
setRoadmapTerm,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
limit = 0,
|
limit = 0,
|
||||||
limitUsed = 0,
|
limitUsed = 0,
|
||||||
|
onLoadTerm,
|
||||||
|
loadAIRoadmapLimit,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const canGenerateMore = limitUsed < limit;
|
const canGenerateMore = limitUsed < limit;
|
||||||
|
const [isConfiguring, setIsConfiguring] = useState(false);
|
||||||
|
const openAPIKey = getOpenAIKey();
|
||||||
|
|
||||||
|
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-grow flex-col items-center justify-center px-4 py-6 sm:px-6">
|
<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">
|
<div className="flex flex-col gap-0 text-center sm:gap-2">
|
||||||
<h1 className="relative text-2xl font-medium sm:text-3xl">
|
<h1 className="relative text-2xl font-medium sm:text-3xl">
|
||||||
<span className="hidden sm:inline">Generate roadmaps with AI</span>
|
<span className="hidden sm:inline">Generate roadmaps with AI</span>
|
||||||
@@ -39,61 +64,77 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<div className="my-3 flex w-full max-w-[600px] flex-col items-center gap-3 sm:my-5">
|
||||||
onSubmit={(e) => {
|
<form
|
||||||
if (limit > 0 && canGenerateMore) {
|
onSubmit={(e) => {
|
||||||
handleSubmit(e);
|
if (limit > 0 && canGenerateMore) {
|
||||||
} else {
|
handleSubmit(e);
|
||||||
e.preventDefault();
|
} else {
|
||||||
}
|
e.preventDefault();
|
||||||
}}
|
}
|
||||||
className="my-3 flex w-full max-w-[600px] flex-col gap-2 sm:my-5 sm:flex-row"
|
}}
|
||||||
>
|
className="flex w-full flex-col gap-2 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,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{limit > 0 && canGenerateMore && (
|
<input
|
||||||
<>
|
autoFocus
|
||||||
<Wand size={20} />
|
type="text"
|
||||||
Generate
|
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 && (
|
{limit > 0 && !canGenerateMore && (
|
||||||
<>
|
<span className="flex items-center text-base">
|
||||||
<span>Please wait..</span>
|
<Ban size={15} className="mr-2" />
|
||||||
</>
|
Limit reached
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
{limit > 0 && !canGenerateMore && (
|
</button>
|
||||||
<span className="flex items-center text-base sm:text-sm">
|
</form>
|
||||||
<Ban size={15} className="mr-2" />
|
<div className="flex flex-row items-center justify-center gap-2 flex-wrap">
|
||||||
Limit reached
|
{randomTerms.map((term) => (
|
||||||
</span>
|
<button
|
||||||
)}
|
key={term}
|
||||||
</button>
|
disabled={!limit || !canGenerateMore}
|
||||||
</form>
|
type="button"
|
||||||
<div className="mb-36">
|
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">
|
<p className="text-gray-500">
|
||||||
<span className="inline sm:hidden">Generated </span>
|
You have generated{' '}
|
||||||
<span className="hidden sm:inline">You have generated </span>
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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':
|
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
|
||||||
!limit,
|
!limit,
|
||||||
@@ -103,16 +144,42 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
{limitUsed} of {limit}
|
{limitUsed} of {limit}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
roadmaps.
|
roadmaps.
|
||||||
{!isLoggedIn && (
|
</p>
|
||||||
<>
|
<p className="flex min-h-[26px] items-center text-sm">
|
||||||
{' '}
|
{limit > 0 && !isLoggedIn() && (
|
||||||
<button
|
<button
|
||||||
className="font-semibold text-black underline underline-offset-2"
|
onClick={showLoginPopup}
|
||||||
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"
|
||||||
>
|
>
|
||||||
Log in to increase your limit
|
Generate more by{' '}
|
||||||
</button>
|
<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>
|
</p>
|
||||||
</div>
|
</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);
|
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();
|
||||||
|
}
|
||||||
|
@@ -26,5 +26,9 @@ export function getRelativeTimeString(date: string): string {
|
|||||||
relativeTime = rtf.format(-diffInDays, 'day');
|
relativeTime = rtf.format(-diffInDays, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (relativeTime === 'this minute') {
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
return relativeTime;
|
return relativeTime;
|
||||||
}
|
}
|
||||||
|
@@ -48,3 +48,39 @@ export function removeAuthToken() {
|
|||||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
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,
|
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 {
|
||||||
|
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