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, Contact, FileText, X } from 'lucide-react'; import { Spinner } from '../ReactIcons/Spinner'; import type { RoadmapNodeDetails } from './GenerateRoadmap'; import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt'; import { cn } from '../../lib/classname'; import { showLoginPopup } from '../../lib/popup'; import { readAIRoadmapContentStream } from '../../lib/ai'; 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 (!isLoggedIn()) { 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-[92]'}> <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" > {isLoggedIn() && ( <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> {!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} > Need to generate more?{' '} <span className="font-semibold">Click here.</span> </button> )} {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> )} {isLoggedIn() && 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> )} {!isLoggedIn() && ( <div className="flex h-full flex-col items-center justify-center"> <Contact className="mb-3.5 h-14 w-14 text-gray-200" /> <h2 className="text-xl font-medium">You must be logged in</h2> <p className="text-base text-gray-400"> Sign up or login to generate topic content. </p> <button className="mt-3.5 w-full max-w-[300px] rounded-md bg-black px-3 py-2 text-base font-medium text-white" onClick={showLoginPopup} > Sign up / Login </button> </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> ); }