From ae790470febefa667297634fb48f61e4af5d68e6 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Tue, 24 Jun 2025 21:05:56 +0600 Subject: [PATCH 1/3] wip: ai roadmap --- src/components/AIRoadmap/AIRoadmap.css | 58 ++++++ src/components/AIRoadmap/AIRoadmap.tsx | 83 ++++++++ src/components/AIRoadmap/AIRoadmapContent.tsx | 35 ++++ .../AIRoadmap/GenerateAIRoadmap.tsx | 122 ++++++++++++ .../ContentGenerator/ContentGenerator.tsx | 11 +- .../GenerateGuide/AIGuideContent.tsx | 4 +- .../GenerateRoadmap/IncreaseRoadmapLimit.tsx | 84 -------- .../GenerateRoadmap/PayToBypass.tsx | 164 ---------------- .../GenerateRoadmap/ReferYourFriend.tsx | 76 -------- src/pages/ai-roadmaps/[aiRoadmapSlug].astro | 36 ++-- src/pages/ai/roadmap/index.astro | 15 ++ src/queries/ai-roadmap.ts | 183 ++++++++++++++++++ 12 files changed, 519 insertions(+), 352 deletions(-) create mode 100644 src/components/AIRoadmap/AIRoadmap.css create mode 100644 src/components/AIRoadmap/AIRoadmap.tsx create mode 100644 src/components/AIRoadmap/AIRoadmapContent.tsx create mode 100644 src/components/AIRoadmap/GenerateAIRoadmap.tsx delete mode 100644 src/components/GenerateRoadmap/IncreaseRoadmapLimit.tsx delete mode 100644 src/components/GenerateRoadmap/PayToBypass.tsx delete mode 100644 src/components/GenerateRoadmap/ReferYourFriend.tsx create mode 100644 src/pages/ai/roadmap/index.astro create mode 100644 src/queries/ai-roadmap.ts 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..8aead8620 --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmap.tsx @@ -0,0 +1,83 @@ +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'; + +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); + const containerRef = useRef(null); + + // only fetch the guide if the guideSlug is provided + // otherwise we are still generating the guide + const { data: aiRoadmap, isLoading: isLoadingBySlug } = useQuery( + aiRoadmapOptions(roadmapSlug, containerRef), + 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)} + randomQuestions={randomQuestions} + isQuestionsLoading={isAiGuideSuggestionsLoading} + /> */} +
+ ); +} diff --git a/src/components/AIRoadmap/AIRoadmapContent.tsx b/src/components/AIRoadmap/AIRoadmapContent.tsx new file mode 100644 index 000000000..d0e8192b4 --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapContent.tsx @@ -0,0 +1,35 @@ +import { cn } from '../../lib/classname'; +import { LoadingChip } from '../LoadingChip'; +import { useEffect, type RefObject } from 'react'; +import { replaceChildren } from '../../lib/dom'; + +type AIRoadmapContentProps = { + svg: SVGElement | null; + isLoading?: boolean; + containerRef: RefObject; +}; + +export function AIRoadmapContent(props: AIRoadmapContentProps) { + const { svg, isLoading, containerRef } = props; + + return ( +
+
+ + {isLoading && !svg && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/AIRoadmap/GenerateAIRoadmap.tsx b/src/components/AIRoadmap/GenerateAIRoadmap.tsx new file mode 100644 index 000000000..f0ede7557 --- /dev/null +++ b/src/components/AIRoadmap/GenerateAIRoadmap.tsx @@ -0,0 +1,122 @@ +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 { replaceChildren } from '../../lib/dom'; + +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 [content, setContent] = useState(''); + const svgRef = useRef(null); + const containerRef = 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, + svg: 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) => { + svgRef.current = svg; + if (containerRef.current) { + replaceChildren(containerRef.current, svg); + } + }, + }); + }; + + if (error) { + return
{error}
; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ ); +} diff --git a/src/components/ContentGenerator/ContentGenerator.tsx b/src/components/ContentGenerator/ContentGenerator.tsx index 3bcaf4451..4589cb577 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'; @@ -55,6 +56,11 @@ export function ContentGenerator() { icon: FileTextIcon, value: 'guide', }, + { + label: 'Roadmap', + icon: MapIcon, + value: 'roadmap', + }, ]; const handleSubmit = () => { @@ -74,6 +80,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}`; } }; @@ -87,6 +95,7 @@ export function ContentGenerator() { const trimmedTitle = title.trim(); const canGenerate = trimmedTitle && trimmedTitle.length >= 3; + return (
@@ -141,7 +150,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. -

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