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