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>pull/5327/head
parent
09cb1ea827
commit
cd6232035f
11 changed files with 1224 additions and 216 deletions
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
Loading…
Reference in new issue