From 423cc80e5781c592d901cc2425127b4899f3290b Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Wed, 25 Jun 2025 00:28:52 +0600 Subject: [PATCH 1/3] feat: roadmap actions --- src/components/AIGuide/AILibraryLayout.tsx | 2 +- src/components/AIRoadmap/AIRoadmap.tsx | 2 +- src/components/AIRoadmap/AIRoadmapActions.tsx | 116 ++++++++++++++ src/components/AIRoadmap/AIRoadmapCard.tsx | 49 ++++++ src/components/AIRoadmap/UserRoadmapsList.tsx | 146 ++++++++++++++++++ src/components/Library/LibraryTab.tsx | 10 +- src/pages/ai/roadmaps.astro | 17 ++ src/queries/ai-roadmap.ts | 35 +++++ 8 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 src/components/AIRoadmap/AIRoadmapActions.tsx create mode 100644 src/components/AIRoadmap/AIRoadmapCard.tsx create mode 100644 src/components/AIRoadmap/UserRoadmapsList.tsx create mode 100644 src/pages/ai/roadmaps.astro diff --git a/src/components/AIGuide/AILibraryLayout.tsx b/src/components/AIGuide/AILibraryLayout.tsx index fb2173aac..f158c3808 100644 --- a/src/components/AIGuide/AILibraryLayout.tsx +++ b/src/components/AIGuide/AILibraryLayout.tsx @@ -5,7 +5,7 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { LibraryTabs } from '../Library/LibraryTab'; type AILibraryLayoutProps = { - activeTab: 'courses' | 'guides'; + activeTab: 'courses' | 'guides' | 'roadmaps'; children: React.ReactNode; }; diff --git a/src/components/AIRoadmap/AIRoadmap.tsx b/src/components/AIRoadmap/AIRoadmap.tsx index 3e3e3e496..b8232cde9 100644 --- a/src/components/AIRoadmap/AIRoadmap.tsx +++ b/src/components/AIRoadmap/AIRoadmap.tsx @@ -1,7 +1,7 @@ import './AIRoadmap.css'; import { useQuery } from '@tanstack/react-query'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { flushSync } from 'react-dom'; import { useToast } from '../../hooks/use-toast'; import { queryClient } from '../../stores/query-client'; diff --git a/src/components/AIRoadmap/AIRoadmapActions.tsx b/src/components/AIRoadmap/AIRoadmapActions.tsx new file mode 100644 index 000000000..211f81346 --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapActions.tsx @@ -0,0 +1,116 @@ +import { MoreVertical, Play, Trash2 } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { useKeydown } from '../../hooks/use-keydown'; +import { useToast } from '../../hooks/use-toast'; +import { useMutation } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; +import { httpDelete } from '../../lib/query-http'; + +type AIRoadmapActionsType = { + roadmapSlug: string; + onDeleted?: () => void; +}; + +export function AIRoadmapActions(props: AIRoadmapActionsType) { + const { roadmapSlug, onDeleted } = props; + + const toast = useToast(); + const dropdownRef = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + + const { mutate: deleteRoadmap, isPending: isDeleting } = useMutation( + { + mutationFn: async () => { + return httpDelete(`/v1-delete-ai-roadmap/${roadmapSlug}`); + }, + onSuccess: () => { + toast.success('Roadmap deleted'); + queryClient.invalidateQueries({ + predicate: (query) => query.queryKey?.[0] === 'user-ai-roadmaps', + }); + onDeleted?.(); + }, + onError: (error) => { + toast.error(error?.message || 'Failed to delete roadmap'); + }, + }, + queryClient, + ); + + useOutsideClick(dropdownRef, () => { + setIsOpen(false); + }); + + useKeydown('Escape', () => { + setIsOpen(false); + }); + + return ( +
+ + + {isOpen && ( +
+ + + Visit Roadmap + + {!isConfirming && ( + + )} + + {isConfirming && ( + + Are you sure? +
+ + +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/AIRoadmap/AIRoadmapCard.tsx b/src/components/AIRoadmap/AIRoadmapCard.tsx new file mode 100644 index 000000000..e61a6ef21 --- /dev/null +++ b/src/components/AIRoadmap/AIRoadmapCard.tsx @@ -0,0 +1,49 @@ +import { CalendarIcon } from 'lucide-react'; +import { getRelativeTimeString } from '../../lib/date'; +import { cn } from '../../lib/classname'; +import type { AIRoadmapDocument } from '../../queries/ai-roadmap'; +import { AIRoadmapActions } from './AIRoadmapActions'; + +type AIRoadmapCardProps = { + roadmap: Omit; + variant?: 'row' | 'column'; + showActions?: boolean; +}; + +export function AIRoadmapCard(props: AIRoadmapCardProps) { + const { roadmap, variant = 'row', showActions = true } = props; + + const updatedAgo = getRelativeTimeString(roadmap?.updatedAt); + + return ( +
+ +
+

+ {roadmap.title} +

+
+ +
+
+ + {updatedAgo} +
+
+
+ + {showActions && roadmap.slug && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/AIRoadmap/UserRoadmapsList.tsx b/src/components/AIRoadmap/UserRoadmapsList.tsx new file mode 100644 index 000000000..72cfff5a4 --- /dev/null +++ b/src/components/AIRoadmap/UserRoadmapsList.tsx @@ -0,0 +1,146 @@ +import { useQuery } from '@tanstack/react-query'; +import { BookOpen, Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser'; +import { queryClient } from '../../stores/query-client'; +import { AITutorTallMessage } from '../AITutor/AITutorTallMessage'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { Pagination } from '../Pagination/Pagination'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { AICourseSearch } from '../GenerateCourse/AICourseSearch'; +import { + listUserAiRoadmapsOptions, + type ListUserAiRoadmapsQuery, +} from '../../queries/ai-roadmap'; +import { AIRoadmapCard } from './AIRoadmapCard'; + +export function UserRoadmapsList() { + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [showUpgradePopup, setShowUpgradePopup] = useState(false); + + const [pageState, setPageState] = useState({ + perPage: '21', + currPage: '1', + query: '', + }); + + const { data: userAiRoadmaps, isFetching: isUserAiRoadmapsLoading } = + useQuery(listUserAiRoadmapsOptions(pageState), queryClient); + + useEffect(() => { + setIsInitialLoading(false); + }, [userAiRoadmaps]); + + const roadmaps = userAiRoadmaps?.data ?? []; + + useEffect(() => { + const queryParams = getUrlParams(); + + setPageState({ + ...pageState, + currPage: queryParams?.p || '1', + query: queryParams?.q || '', + }); + }, []); + + useEffect(() => { + if (pageState?.currPage !== '1' || pageState?.query !== '') { + setUrlParams({ + p: pageState?.currPage || '1', + q: pageState?.query || '', + }); + } else { + deleteUrlParam('p'); + deleteUrlParam('q'); + } + }, [pageState]); + + const isUserAuthenticated = isLoggedIn(); + const isAnyLoading = isUserAiRoadmapsLoading || isInitialLoading; + + return ( + <> + {showUpgradePopup && ( + setShowUpgradePopup(false)} /> + )} + + { + setPageState({ + ...pageState, + query: value, + currPage: '1', + }); + }} + placeholder="Search Roadmaps..." + disabled={isAnyLoading} + /> + + {isAnyLoading && ( +

+ + Loading your courses... +

+ )} + + {!isAnyLoading && ( + <> +

+ {isUserAuthenticated + ? `You have generated ${userAiRoadmaps?.totalCount} roadmaps so far.` + : 'Sign up or login to generate your first roadmap. Takes 2s to do so.'} +

+ + {isUserAuthenticated && !isAnyLoading && roadmaps.length > 0 && ( +
+
+ {roadmaps.map((roadmap) => ( + + ))} +
+ + { + setPageState({ ...pageState, currPage: String(page) }); + }} + className="rounded-lg border border-gray-200 bg-white p-4" + /> +
+ )} + + {!isAnyLoading && roadmaps.length === 0 && ( + { + if (isUserAuthenticated) { + window.location.href = '/ai'; + } else { + showLoginPopup(); + } + }} + /> + )} + + )} + + ); +} diff --git a/src/components/Library/LibraryTab.tsx b/src/components/Library/LibraryTab.tsx index 18d3c445f..ceb0aa06b 100644 --- a/src/components/Library/LibraryTab.tsx +++ b/src/components/Library/LibraryTab.tsx @@ -1,8 +1,8 @@ -import { BookOpen, FileTextIcon, type LucideIcon } from 'lucide-react'; +import { BookOpen, FileTextIcon, MapIcon, type LucideIcon } from 'lucide-react'; import { cn } from '../../lib/classname'; type LibraryTabsProps = { - activeTab: 'guides' | 'courses'; + activeTab: 'guides' | 'courses' | 'roadmaps'; }; export function LibraryTabs(props: LibraryTabsProps) { @@ -22,6 +22,12 @@ export function LibraryTabs(props: LibraryTabsProps) { label="Guides" href="/ai/guides" /> + ); } diff --git a/src/pages/ai/roadmaps.astro b/src/pages/ai/roadmaps.astro new file mode 100644 index 000000000..0c17ac982 --- /dev/null +++ b/src/pages/ai/roadmaps.astro @@ -0,0 +1,17 @@ +--- +import { UserRoadmapsList } from '../../components/AIRoadmap/UserRoadmapsList'; +import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; +import { AILibraryLayout } from '../../components/AIGuide/AILibraryLayout'; +const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; +--- + + + + + + diff --git a/src/queries/ai-roadmap.ts b/src/queries/ai-roadmap.ts index ce4f7725c..200d1e759 100644 --- a/src/queries/ai-roadmap.ts +++ b/src/queries/ai-roadmap.ts @@ -51,6 +51,8 @@ import { queryClient } from '../stores/query-client'; import { getAiCourseLimitOptions } from '../queries/ai-course'; import { readChatStream } from '../lib/chat'; import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat'; +import type { AIGuideDocument } from './ai-guide'; +import { isLoggedIn } from '../lib/jwt'; type RoadmapDetails = { roadmapId: string; @@ -176,3 +178,36 @@ export async function generateAIRoadmap(options: GenerateAIRoadmapOptions) { onStreamingChange?.(false); } } + +export type ListUserAiRoadmapsQuery = { + perPage?: string; + currPage?: string; + query?: string; +}; + +export type ListUserAiRoadmapsResponse = { + data: Omit[]; + totalCount: number; + totalPages: number; + currPage: number; + perPage: number; +}; + +export function listUserAiRoadmapsOptions( + params: ListUserAiRoadmapsQuery = { + perPage: '21', + currPage: '1', + query: '', + }, +) { + return queryOptions({ + queryKey: ['user-ai-roadmaps', params], + queryFn: () => { + return httpGet( + `/v1-list-user-ai-roadmaps`, + params, + ); + }, + enabled: !!isLoggedIn(), + }); +} From cca807248e3e1ce27a37d108ee6bfe6c711a2c3a Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Wed, 25 Jun 2025 00:32:08 +0600 Subject: [PATCH 2/3] wip --- src/components/AIGuide/AILibraryLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AIGuide/AILibraryLayout.tsx b/src/components/AIGuide/AILibraryLayout.tsx index f158c3808..8e8e50ed1 100644 --- a/src/components/AIGuide/AILibraryLayout.tsx +++ b/src/components/AIGuide/AILibraryLayout.tsx @@ -22,7 +22,7 @@ export function AILibraryLayout(props: AILibraryLayoutProps) {
setShowUpgradePopup(true)} /> From 79e274190feb050350fa4d1431dbd7c314ad3524 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Wed, 25 Jun 2025 00:38:10 +0600 Subject: [PATCH 3/3] fix: hydration error --- .../GenerateCourse/AICourseContent.tsx | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index 78e8570cc..9a71d89cc 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -256,12 +256,14 @@ export function AICourseContent(props: AICourseContentProps) {
-
- setShowUpgradeModal(true)} - onShowLimits={() => setShowAILimitsPopup(true)} - /> -
+ {!isLoading && ( +
+ setShowUpgradeModal(true)} + onShowLimits={() => setShowAILimitsPopup(true)} + /> +
+ )} {viewMode === 'module' && (
-
-
- setShowUpgradeModal(true)} - onShowLimits={() => setShowAILimitsPopup(true)} - /> + {!isLoading && ( +
+
+ setShowUpgradeModal(true)} + onShowLimits={() => setShowAILimitsPopup(true)} + /> +
-
+ )}