diff --git a/src/components/AITutor/AIExploreCourseListing.tsx b/src/components/AITutor/AIExploreCourseListing.tsx new file mode 100644 index 000000000..65b57f1b2 --- /dev/null +++ b/src/components/AITutor/AIExploreCourseListing.tsx @@ -0,0 +1,96 @@ +import { useListExploreAiCourses } from '../../queries/ai-course'; +import { useEffect, useState } from 'react'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import { AICourseCard } from '../GenerateCourse/AICourseCard'; + +type AIExploreCourseListingProps = {}; + +export function AIExploreCourseListing(props: AIExploreCourseListingProps) { + const [isInitialLoading, setIsInitialLoading] = useState(true); + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + status, + isLoading: isExploreAiCoursesLoading, + } = useListExploreAiCourses(); + + useEffect(() => { + setIsInitialLoading(false); + }, [data]); + + const courses = data?.pages.flatMap((page) => page.data) ?? []; + + return ( + <> +
+
+

Explore Courses

+
+
+ + {(isExploreAiCoursesLoading || isInitialLoading) && ( +
+ +

Loading...

+
+ )} + + {error && !isExploreAiCoursesLoading && !isInitialLoading && ( +
+ +

+ {error?.message ?? 'Error loading courses.'} +

+
+ )} + + {!isExploreAiCoursesLoading && + courses && + courses.length > 0 && + !error && ( +
+ {courses.map((course) => ( + + ))} +
+ )} + + {hasNextPage && !isFetchingNextPage && !error && ( +
+ +
+ )} + + {isFetchingNextPage && !error && ( +
+ +

+ Loading more courses... +

+
+ )} + + ); +} diff --git a/src/components/AITutor/AIFeaturedCoursesListing.tsx b/src/components/AITutor/AIFeaturedCoursesListing.tsx new file mode 100644 index 000000000..b65cd022f --- /dev/null +++ b/src/components/AITutor/AIFeaturedCoursesListing.tsx @@ -0,0 +1,98 @@ +import { useQuery } from '@tanstack/react-query'; +import { + listUserAiCoursesOptions, + type ListUserAiCoursesQuery, +} from '../../queries/ai-course'; +import { queryClient } from '../../stores/query-client'; +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { getUrlParams, setUrlParams, deleteUrlParam } from '../../lib/browser'; +import { AICourseCard } from '../GenerateCourse/AICourseCard'; + +type AIFeaturedCoursesListingProps = {}; + +export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { + const [isInitialLoading, setIsInitialLoading] = useState(true); + + const [pageState, setPageState] = useState({ + perPage: '10', + currPage: '1', + query: '', + }); + + const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery( + listUserAiCoursesOptions(pageState), + queryClient, + ); + + useEffect(() => { + setIsInitialLoading(false); + }, [userAiCourses]); + + const courses = userAiCourses?.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]); + + return ( + <> +
+
+

Stuff Picks

+
+
+ + {(isUserAiCoursesLoading || isInitialLoading) && ( +
+ +

Loading...

+
+ )} + + {!isUserAiCoursesLoading && courses && courses.length > 0 && ( +
+ {courses.map((course) => ( + + ))} +
+ )} + + {!isUserAiCoursesLoading && + (userAiCourses?.data?.length || 0 > 0) && + courses.length === 0 && ( +
+

+ No courses match your search. +

+
+ )} + + ); +} diff --git a/src/components/AITutor/AITutorLayout.tsx b/src/components/AITutor/AITutorLayout.tsx new file mode 100644 index 000000000..18b23649f --- /dev/null +++ b/src/components/AITutor/AITutorLayout.tsx @@ -0,0 +1,17 @@ +import { AITutorSidebar, type AITutorTab } from './AITutorSidebar'; + +type AITutorLayoutProps = { + children: React.ReactNode; + activeTab: AITutorTab; +}; + +export function AITutorLayout(props: AITutorLayoutProps) { + const { children, activeTab } = props; + + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index 2ef0ca0c8..2d1f15fe0 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -1,14 +1,20 @@ -import { ChevronLeft, PlusCircle, BookOpen, Compass } from 'lucide-react'; +import { + ChevronLeft, + PlusCircle, + BookOpen, + Compass, + CircleDotIcon, +} from 'lucide-react'; type AITutorSidebarProps = { - activeTab: 'new' | 'courses' | 'explore'; + activeTab: AITutorTab; }; const sidebarItems = [ { key: 'new', label: 'New Course', - href: '/ai/new', + href: '/ai', icon: PlusCircle, }, { @@ -17,6 +23,12 @@ const sidebarItems = [ href: '/ai/courses', icon: BookOpen, }, + { + key: 'stuff-picks', + label: 'Stuff Picks', + href: '/ai/stuff-picks', + icon: CircleDotIcon, + }, { key: 'explore', label: 'Explore', @@ -25,6 +37,8 @@ const sidebarItems = [ }, ]; +export type AITutorTab = (typeof sidebarItems)[number]['key']; + export function AITutorSidebar(props: AITutorSidebarProps) { const { activeTab } = props; diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index afbb3fcbf..5708a505c 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -97,7 +97,7 @@ export function AICourse(props: AICourseProps) { Course Topic
-
+
setKeyword(e.target.value)} onKeyDown={handleKeyDown} placeholder="e.g., Algebra, JavaScript, Photography" - className="w-full rounded-md border border-gray-300 bg-white p-3 pl-10 text-gray-900 focus:outline-hidden focus:ring-1 focus:ring-gray-500 max-sm:placeholder:text-base" + className="w-full rounded-md border border-gray-300 bg-white p-3 pl-10 text-gray-900 focus:ring-1 focus:ring-gray-500 focus:outline-hidden max-sm:placeholder:text-base" maxLength={50} />
@@ -162,10 +162,6 @@ export function AICourse(props: AICourseProps) {
- -
- -
); diff --git a/src/components/GenerateCourse/AICourseCard.tsx b/src/components/GenerateCourse/AICourseCard.tsx index 5c524885a..12e4bf09b 100644 --- a/src/components/GenerateCourse/AICourseCard.tsx +++ b/src/components/GenerateCourse/AICourseCard.tsx @@ -5,10 +5,12 @@ import { AICourseActions } from './AICourseActions'; type AICourseCardProps = { course: AICourseWithLessonCount; + showActions?: boolean; + showProgress?: boolean; }; export function AICourseCard(props: AICourseCardProps) { - const { course } = props; + const { course, showActions = true, showProgress = true } = props; // Format date if available const formattedDate = course.createdAt @@ -56,7 +58,7 @@ export function AICourseCard(props: AICourseCardProps) { {totalTopics} lessons - {totalTopics > 0 && ( + {showProgress && totalTopics > 0 && (
- {course.slug && ( -
+ {showActions && course.slug && ( +
)} diff --git a/src/pages/ai/courses.astro b/src/pages/ai/courses.astro new file mode 100644 index 000000000..e54254731 --- /dev/null +++ b/src/pages/ai/courses.astro @@ -0,0 +1,21 @@ +--- +import { UserCoursesList } from '../../components/GenerateCourse/UserCoursesList'; +import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; +import { AITutorLayout } from '../../components/AITutor/AITutorLayout'; +const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; +--- + + + +
+
+ +
+
+
+
diff --git a/src/pages/ai/explore.astro b/src/pages/ai/explore.astro new file mode 100644 index 000000000..60f0e352a --- /dev/null +++ b/src/pages/ai/explore.astro @@ -0,0 +1,21 @@ +--- +import { AIExploreCourseListing } from '../../components/AITutor/AIExploreCourseListing'; +import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; +import { AITutorLayout } from '../../components/AITutor/AITutorLayout'; +const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; +--- + + + +
+
+ +
+
+
+
diff --git a/src/pages/ai/index.astro b/src/pages/ai/index.astro index c0e6a1f6f..16db0eceb 100644 --- a/src/pages/ai/index.astro +++ b/src/pages/ai/index.astro @@ -3,7 +3,7 @@ import { ChevronLeft, PlusCircle, BookOpen, Compass } from 'lucide-react'; import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification'; import { AICourse } from '../../components/GenerateCourse/AICourse'; import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; -import { AITutorSidebar } from '../../components/AITutor/AITutorSidebar'; +import { AITutorLayout } from '../../components/AITutor/AITutorLayout'; const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; --- @@ -13,11 +13,8 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; ogImageUrl={ogImage} description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.' > -
- -
- - -
-
+ + + + diff --git a/src/pages/ai/stuff-picks.astro b/src/pages/ai/stuff-picks.astro new file mode 100644 index 000000000..ad7ad5f74 --- /dev/null +++ b/src/pages/ai/stuff-picks.astro @@ -0,0 +1,21 @@ +--- +import { AIFeaturedCoursesListing } from '../../components/AITutor/AIFeaturedCoursesListing'; +import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; +import { AITutorLayout } from '../../components/AITutor/AITutorLayout'; +const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; +--- + + + +
+
+ +
+
+
+
diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts index 0a955bb12..0ab1a601c 100644 --- a/src/queries/ai-course.ts +++ b/src/queries/ai-course.ts @@ -1,6 +1,7 @@ import { httpGet } from '../lib/query-http'; import { isLoggedIn } from '../lib/jwt'; -import { queryOptions } from '@tanstack/react-query'; +import { queryOptions, useInfiniteQuery } from '@tanstack/react-query'; +import { queryClient } from '../stores/query-client'; export interface AICourseProgressDocument { _id: string; @@ -99,3 +100,48 @@ export function listUserAiCoursesOptions( enabled: !!isLoggedIn(), }; } + +type ListExploreAiCoursesParams = {}; + +type ListExploreAiCoursesQuery = { + perPage?: string; + currPage?: string; +}; + +type ListExploreAiCoursesResponse = { + data: AICourseWithLessonCount[]; + currPage: number; + perPage: number; +}; + +export function useListExploreAiCourses() { + return useInfiniteQuery( + { + queryKey: ['explore-ai-courses'], + queryFn: ({ pageParam = 1 }) => { + return httpGet( + `/v1-list-explore-ai-courses`, + { + perPage: '20', + currPage: String(pageParam), + }, + ); + }, + getNextPageParam: (lastPage, pages, lastPageParam) => { + if (lastPage?.data?.length === 0) { + return undefined; + } + + return lastPageParam + 1; + }, + getPreviousPageParam: (firstPage, allPages, firstPageParam) => { + if (firstPageParam <= 1) { + return undefined; + } + return firstPageParam - 1; + }, + initialPageParam: 1, + }, + queryClient, + ); +}