diff --git a/src/components/AIGuide/AIGuideCard.tsx b/src/components/AIGuide/AIGuideCard.tsx index c5f96c1d8..273da8e0a 100644 --- a/src/components/AIGuide/AIGuideCard.tsx +++ b/src/components/AIGuide/AIGuideCard.tsx @@ -11,30 +11,15 @@ type AIGuideCardProps = { export function AIGuideCard(props: AIGuideCardProps) { const { guide, showActions = true } = props; - const guideDepthColor = - { - essentials: 'text-green-700', - detailed: 'text-blue-700', - complete: 'text-purple-700', - }[guide.depth] || 'text-gray-700'; - return (
-
- - {guide.depth} - -
-
diff --git a/src/components/AIRoadmap/AIRoadmap.css b/src/components/AIRoadmap/AIRoadmap.css new file mode 100644 index 000000000..d30ca465a --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmap.css @@ -0,0 +1,58 @@ +@font-face { + font-family: 'balsamiq'; + src: url('/fonts/balsamiq.woff2'); +} + +svg text tspan { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeSpeed; +} + +svg > g[data-type='topic'], +svg > g[data-type='subtopic'], +svg > g > g[data-type='link-item'], +svg > g[data-type='button'] { + cursor: pointer; +} + +svg > g[data-type='topic']:hover > rect { + fill: #d6d700; +} + +svg > g[data-type='subtopic']:hover > rect { + fill: #f3c950; +} +svg > g[data-type='button']:hover { + opacity: 0.8; +} + +svg .done rect { + fill: #cbcbcb !important; +} + +svg .done text, +svg .skipped text { + text-decoration: line-through; +} + +svg > g[data-type='topic'].learning > rect + text, +svg > g[data-type='topic'].done > rect + text { + fill: black; +} + +svg > g[data-type='subtipic'].done > rect + text, +svg > g[data-type='subtipic'].learning > rect + text { + fill: #cbcbcb; +} + +svg .learning rect { + fill: #dad1fd !important; +} +svg .learning text { + text-decoration: underline; +} + +svg .skipped rect { + fill: #496b69 !important; +} diff --git a/src/components/AIRoadmap/AIRoadmap.tsx b/src/components/AIRoadmap/AIRoadmap.tsx new file mode 100644 index 000000000..a252a8f29 --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmap.tsx @@ -0,0 +1,80 @@ +import './AIRoadmap.css'; + +import { useQuery } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { useToast } from '../../hooks/use-toast'; +import { queryClient } from '../../stores/query-client'; +import { AITutorLayout } from '../AITutor/AITutorLayout'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { aiRoadmapOptions } from '../../queries/ai-roadmap'; +import { GenerateAIRoadmap } from './GenerateAIRoadmap'; +import { AIRoadmapContent } from './AIRoadmapContent'; +import { AIRoadmapChat } from './AIRoadmapChat'; + +type AIRoadmapProps = { + roadmapSlug?: string; +}; + +export function AIRoadmap(props: AIRoadmapProps) { + const { roadmapSlug: defaultRoadmapSlug } = props; + const [roadmapSlug, setRoadmapSlug] = useState(defaultRoadmapSlug); + + const toast = useToast(); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); + + // only fetch the guide if the guideSlug is provided + // otherwise we are still generating the guide + const { data: aiRoadmap, isLoading: isLoadingBySlug } = useQuery( + aiRoadmapOptions(roadmapSlug), + queryClient, + ); + + const handleRegenerate = async (prompt?: string) => { + flushSync(() => { + setIsRegenerating(true); + }); + + queryClient.cancelQueries(aiRoadmapOptions(roadmapSlug)); + queryClient.setQueryData(aiRoadmapOptions(roadmapSlug).queryKey, (old) => { + if (!old) { + return old; + } + + return { + ...old, + data: '', + svg: null, + }; + }); + }; + + return ( + + {showUpgradeModal && ( + setShowUpgradeModal(false)} /> + )} + +
+ {roadmapSlug && ( + + )} + {!roadmapSlug && ( + + )} +
+ setShowUpgradeModal(true)} + /> +
+ ); +} diff --git a/src/components/AIRoadmap/AIRoadmapChat.tsx b/src/components/AIRoadmap/AIRoadmapChat.tsx new file mode 100644 index 000000000..557f89b7e --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapChat.tsx @@ -0,0 +1,338 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { useChat, type ChatMessage } from '../../hooks/use-chat'; +import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard'; +import { + ArrowDownIcon, + BotIcon, + LockIcon, + MessageCircleIcon, + PauseCircleIcon, + SendIcon, + Trash2Icon, + XIcon, +} from 'lucide-react'; +import { ChatHeaderButton } from '../FrameRenderer/RoadmapFloatingChat'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { flushSync } from 'react-dom'; +import { markdownToHtml } from '../../lib/markdown'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { useQuery } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; +import { billingDetailsOptions } from '../../queries/billing'; +import { LoadingChip } from '../LoadingChip'; +import { getTailwindScreenDimension } from '../../lib/is-mobile'; +import { useToast } from '../../hooks/use-toast'; + +type AIRoadmapChatProps = { + roadmapSlug?: string; + isRoadmapLoading?: boolean; + onUpgrade?: () => void; +}; + +export function AIRoadmapChat(props: AIRoadmapChatProps) { + const { roadmapSlug, isRoadmapLoading, onUpgrade } = props; + + const toast = useToast(); + const scrollareaRef = useRef(null); + const inputRef = useRef(null); + + const [inputValue, setInputValue] = useState(''); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [isChatOpen, setIsChatOpen] = useState(true); + const [isMobile, setIsMobile] = useState(false); + + const { + data: tokenUsage, + isLoading: isTokenUsageLoading, + refetch: refetchTokenUsage, + } = useQuery(getAiCourseLimitOptions(), queryClient); + + const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = + useQuery(billingDetailsOptions(), queryClient); + + const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); + const isPaidUser = userBillingDetails?.status === 'active'; + + const { + messages, + status, + streamedMessageHtml, + sendMessages, + setMessages, + stop, + } = useChat({ + endpoint: `${import.meta.env.PUBLIC_API_URL}/v1-ai-roadmap-chat`, + onError: (error) => { + console.error(error); + toast.error(error?.message || 'Something went wrong'); + }, + data: { + aiRoadmapSlug: roadmapSlug, + }, + onFinish: () => { + refetchTokenUsage(); + }, + }); + + const scrollToBottom = useCallback( + (behavior: 'smooth' | 'instant' = 'smooth') => { + scrollareaRef.current?.scrollTo({ + top: scrollareaRef.current.scrollHeight, + behavior, + }); + }, + [scrollareaRef], + ); + + const isStreamingMessage = status === 'streaming'; + const hasMessages = messages.length > 0; + + const handleSubmitInput = useCallback( + (defaultInputValue?: string) => { + const message = defaultInputValue || inputValue; + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + if (isStreamingMessage) { + return; + } + + const newMessages: ChatMessage[] = [ + ...messages, + { + role: 'user', + content: message, + html: markdownToHtml(message), + }, + ]; + flushSync(() => { + setMessages(newMessages); + }); + sendMessages(newMessages); + setInputValue(''); + }, + [inputValue, isStreamingMessage, messages, sendMessages, setMessages], + ); + + const checkScrollPosition = useCallback(() => { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = scrollArea; + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold + setShowScrollToBottom(!isAtBottom && messages.length > 0); + }, [messages.length]); + + useEffect(() => { + const scrollArea = scrollareaRef.current; + if (!scrollArea) { + return; + } + + scrollArea.addEventListener('scroll', checkScrollPosition); + return () => scrollArea.removeEventListener('scroll', checkScrollPosition); + }, [checkScrollPosition]); + + const isLoading = + isRoadmapLoading || isTokenUsageLoading || isBillingDetailsLoading; + + useLayoutEffect(() => { + const deviceType = getTailwindScreenDimension(); + const isMediumSize = ['sm', 'md'].includes(deviceType); + + if (!isMediumSize) { + const storedState = localStorage.getItem('chat-history-sidebar-open'); + setIsChatOpen(storedState === null ? true : storedState === 'true'); + } else { + setIsChatOpen(!isMediumSize); + } + + setIsMobile(isMediumSize); + }, []); + + useEffect(() => { + if (!isMobile) { + localStorage.setItem('chat-history-sidebar-open', isChatOpen.toString()); + } + }, [isChatOpen, isMobile]); + + if (!isChatOpen) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ + AI Roadmap +

+ + +
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && ( + <> +
+
+
+
+ + + {messages.map((chat, index) => { + return ( + + ); + })} + + {status === 'streaming' && !streamedMessageHtml && ( + + )} + + {status === 'streaming' && streamedMessageHtml && ( + + )} +
+
+
+
+ + {(hasMessages || showScrollToBottom) && ( +
+ } + className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300" + onClick={() => { + setMessages([]); + }} + > + Clear + + {showScrollToBottom && ( + } + className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300" + onClick={() => { + scrollToBottom('smooth'); + }} + > + Scroll to bottom + + )} +
+ )} + +
+ {isLimitExceeded && isLoggedIn() && ( +
+ +

+ Limit reached for today + {isPaidUser ? '. Please wait until tomorrow.' : ''} +

+ {!isPaidUser && ( + + )} +
+ )} + + setInputValue(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (isStreamingMessage) { + return; + } + handleSubmitInput(); + } + }} + placeholder="Ask me anything about this roadmap..." + className="w-full resize-none px-3 py-4 outline-none" + /> + + +
+ + )} +
+ ); +} diff --git a/src/components/AIRoadmap/AIRoadmapContent.tsx b/src/components/AIRoadmap/AIRoadmapContent.tsx new file mode 100644 index 000000000..41f37b18e --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapContent.tsx @@ -0,0 +1,32 @@ +import { cn } from '../../lib/classname'; +import { LoadingChip } from '../LoadingChip'; + +type AIRoadmapContentProps = { + isLoading?: boolean; + svgHtml: string; +}; + +export function AIRoadmapContent(props: AIRoadmapContentProps) { + const { isLoading, svgHtml } = props; + + return ( +
+
+ + {isLoading && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/AIRoadmap/GenerateAIRoadmap.tsx b/src/components/AIRoadmap/GenerateAIRoadmap.tsx new file mode 100644 index 000000000..fe6eb2957 --- /dev/null +++ b/src/components/AIRoadmap/GenerateAIRoadmap.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from 'react'; +import { getUrlParams } from '../../lib/browser'; +import { isLoggedIn } from '../../lib/jwt'; +import { queryClient } from '../../stores/query-client'; +import { LoadingChip } from '../LoadingChip'; +import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat'; +import { getQuestionAnswerChatMessages } from '../../lib/ai-questions'; +import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap'; +import { AIRoadmapContent } from './AIRoadmapContent'; + +type GenerateAIRoadmapProps = { + onRoadmapSlugChange?: (roadmapSlug: string) => void; +}; + +export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) { + const { onRoadmapSlugChange } = props; + + const [isLoading, setIsLoading] = useState(true); + const [isStreaming, setIsStreaming] = useState(false); + const [error, setError] = useState(''); + + const [svgHtml, setSvgHtml] = useState(''); + const [content, setContent] = useState(''); + const svgRef = useRef(null); + + useEffect(() => { + const params = getUrlParams(); + const paramsTerm = params?.term; + const paramsSrc = params?.src || 'search'; + if (!paramsTerm) { + return; + } + + let questionAndAnswers: QuestionAnswerChatMessage[] = []; + const sessionId = params?.id; + if (sessionId) { + questionAndAnswers = getQuestionAnswerChatMessages(sessionId); + } + + handleGenerateDocument({ + term: paramsTerm, + src: paramsSrc, + questionAndAnswers, + }); + }, []); + + const handleGenerateDocument = async (options: { + term: string; + isForce?: boolean; + prompt?: string; + src?: string; + questionAndAnswers?: QuestionAnswerChatMessage[]; + }) => { + const { term, isForce, prompt, src, questionAndAnswers } = options; + + if (!isLoggedIn()) { + window.location.href = '/ai'; + return; + } + + await generateAIRoadmap({ + term, + isForce, + prompt, + questionAndAnswers, + onDetailsChange: (details) => { + const { roadmapId, roadmapSlug, title, userId } = details; + + const aiRoadmapData = { + _id: roadmapId, + userId, + title, + term, + data: content, + questionAndAnswers, + viewCount: 0, + svgHtml: svgRef.current || '', + lastVisitedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + queryClient.setQueryData( + aiRoadmapOptions(roadmapSlug).queryKey, + aiRoadmapData, + ); + + onRoadmapSlugChange?.(roadmapSlug); + window.history.replaceState(null, '', `/ai-roadmaps/${roadmapSlug}`); + }, + onLoadingChange: setIsLoading, + onError: setError, + onStreamingChange: setIsStreaming, + onRoadmapSvgChange: (svg) => { + const svgHtml = svg.outerHTML; + svgRef.current = svgHtml; + setSvgHtml(svgHtml); + }, + }); + }; + + if (error) { + return
{error}
; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ; +} diff --git a/src/components/ContentGenerator/ContentGenerator.tsx b/src/components/ContentGenerator/ContentGenerator.tsx index 849149b3e..5737ef345 100644 --- a/src/components/ContentGenerator/ContentGenerator.tsx +++ b/src/components/ContentGenerator/ContentGenerator.tsx @@ -1,6 +1,7 @@ import { BookOpenIcon, FileTextIcon, + MapIcon, SparklesIcon, type LucideIcon, } from 'lucide-react'; @@ -56,6 +57,11 @@ export function ContentGenerator() { icon: FileTextIcon, value: 'guide', }, + { + label: 'Roadmap', + icon: MapIcon, + value: 'roadmap', + }, ]; const handleSubmit = () => { @@ -75,6 +81,8 @@ export function ContentGenerator() { window.location.href = `/ai/course?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`; } else if (selectedFormat === 'guide') { window.location.href = `/ai/guide?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`; + } else if (selectedFormat === 'roadmap') { + window.location.href = `/ai/roadmap?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`; } }; @@ -88,6 +96,7 @@ export function ContentGenerator() { const trimmedTitle = title.trim(); const canGenerate = trimmedTitle && trimmedTitle.length >= 3; + return (
@@ -142,7 +151,7 @@ export function ContentGenerator() { -
+
{allowedFormats.map((format) => { const isSelected = format.value === selectedFormat; diff --git a/src/components/GenerateGuide/AIGuideContent.tsx b/src/components/GenerateGuide/AIGuideContent.tsx index 470f30e12..d968f41c8 100644 --- a/src/components/GenerateGuide/AIGuideContent.tsx +++ b/src/components/GenerateGuide/AIGuideContent.tsx @@ -8,7 +8,7 @@ type AIGuideContentProps = { html: string; onRegenerate?: (prompt?: string) => void; isLoading?: boolean; - guideSlug: string; + guideSlug?: string; }; export function AIGuideContent(props: AIGuideContentProps) { @@ -32,7 +32,7 @@ export function AIGuideContent(props: AIGuideContentProps) {
)} - {onRegenerate && !isLoading && ( + {onRegenerate && !isLoading && guideSlug && (
void; -}; - -export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) { - const { onClose } = props; - - const user = useAuth(); - const toast = useToast(); - const inputRef = useRef(null); - - const { copyText, isCopied } = useCopyText(); - const referralLink = new URL( - `/ai?rc=${user?.id}`, - import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh', - ).toString(); - - const handleCopy = () => { - inputRef.current?.select(); - copyText(referralLink); - toast.success('Copied to clipboard'); - }; - - return ( - -
-

- Refer your Friends -

-

- Share the URL below with your friends. When they sign up with your - link, you will get extra roadmap generation credits. -

- - -
-
- ); -} diff --git a/src/components/GenerateRoadmap/PayToBypass.tsx b/src/components/GenerateRoadmap/PayToBypass.tsx deleted file mode 100644 index 7b40c9fff..000000000 --- a/src/components/GenerateRoadmap/PayToBypass.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { ChevronLeft } from 'lucide-react'; -import { useAuth } from '../../hooks/use-auth'; - -type PayToBypassProps = { - onBack: () => void; - onClose: () => void; -}; - -export function PayToBypass(props: PayToBypassProps) { - const { onBack, onClose } = props; - const user = useAuth(); - - const userId = 'entry.1665642993'; - const nameId = 'entry.527005328'; - const emailId = 'entry.982906376'; - const amountId = 'entry.1826002937'; - const roadmapCountId = 'entry.1161404075'; - const usageId = 'entry.535914744'; - const feedbackId = 'entry.1024388959'; - - return ( -
- - -

Pay to Bypass

-

- Tell us more about how you will be using this. -

- -
- - - - -
- - -
-
- -