From 3a20912f0f9666c253795dbef9a39c817bc6b679 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Tue, 8 Apr 2025 13:52:40 +0100 Subject: [PATCH 01/31] Add sidebar to ai-tutor --- src/components/AITutor/AITutorSidebar.tsx | 66 +++++++++++++++++++ .../GenerateCourse/FineTuneCourse.tsx | 3 +- src/pages/ai/index.astro | 21 +++--- 3 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 src/components/AITutor/AITutorSidebar.tsx diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx new file mode 100644 index 000000000..2ef0ca0c8 --- /dev/null +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -0,0 +1,66 @@ +import { ChevronLeft, PlusCircle, BookOpen, Compass } from 'lucide-react'; + +type AITutorSidebarProps = { + activeTab: 'new' | 'courses' | 'explore'; +}; + +const sidebarItems = [ + { + key: 'new', + label: 'New Course', + href: '/ai/new', + icon: PlusCircle, + }, + { + key: 'courses', + label: 'My Courses', + href: '/ai/courses', + icon: BookOpen, + }, + { + key: 'explore', + label: 'Explore', + href: '/ai/explore', + icon: Compass, + }, +]; + +export function AITutorSidebar(props: AITutorSidebarProps) { + const { activeTab } = props; + + return ( + <div className="flex w-[240px] flex-col border-r border-gray-200 bg-gradient-to-b from-white to-gray-50"> + <a + href="https://roadmap.sh" + className="flex w-full items-center justify-start gap-1.5 border-b border-gray-200 px-5 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-black" + > + <ChevronLeft className="size-4" /> + Back to <span className="font-semibold text-black">roadmap.sh</span> + </a> + + <div className="px-6 pt-6 pb-2"> + <h2 className="text-lg font-semibold text-gray-900">Learn with AI</h2> + <p className="mt-1 text-sm text-gray-500"> + Your personalized learning companion for any topic + </p> + </div> + + <div className="flex-1 px-3 py-3"> + <nav className="space-y-1"> + {sidebarItems.map((item) => ( + <a + key={item.key} + href={item.href} + className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-700 transition-all hover:bg-gray-100 hover:text-black ${ + activeTab === item.key ? 'bg-gray-100 text-black' : '' + }`} + > + <item.icon className="size-4" /> + {item.label} + </a> + ))} + </nav> + </div> + </div> + ); +} diff --git a/src/components/GenerateCourse/FineTuneCourse.tsx b/src/components/GenerateCourse/FineTuneCourse.tsx index 44d967b0f..e1641ca59 100644 --- a/src/components/GenerateCourse/FineTuneCourse.tsx +++ b/src/components/GenerateCourse/FineTuneCourse.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { cn } from '../../lib/classname'; type QuestionProps = { @@ -70,7 +69,7 @@ export function FineTuneCourse(props: FineTuneCourseProps) { }} /> Tell us more to tailor the course (optional){' '} - <span className="ml-auto rounded-md bg-gray-400 px-2 py-0.5 text-xs text-white"> + <span className="ml-auto rounded-md bg-gray-400 px-2 py-0.5 text-xs text-white hidden sm:block"> recommended </span> </label> diff --git a/src/pages/ai/index.astro b/src/pages/ai/index.astro index 3a67adf52..c0e6a1f6f 100644 --- a/src/pages/ai/index.astro +++ b/src/pages/ai/index.astro @@ -1,18 +1,23 @@ --- -import { AICourse } from '../../components/GenerateCourse/AICourse'; -import BaseLayout from '../../layouts/BaseLayout.astro'; +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'; const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; --- -<BaseLayout +<SkeletonLayout title='Roadmap AI' noIndex={true} 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.' > - <div slot='course-announcement'></div> - <AICourse client:load /> - <CheckSubscriptionVerification client:load /> -</BaseLayout> + <div class='flex flex-grow flex-row'> + <AITutorSidebar client:load activeTab='new' /> + <div class='flex flex-grow flex-col'> + <AICourse client:load /> + <CheckSubscriptionVerification client:load /> + </div> + </div> +</SkeletonLayout> From da14f050796cfabf324a00c110dee1a56d38c0e4 Mon Sep 17 00:00:00 2001 From: Arik Chakma <arikchangma@gmail.com> Date: Wed, 9 Apr 2025 01:39:51 +0600 Subject: [PATCH 02/31] wip --- .../AITutor/AIExploreCourseListing.tsx | 96 ++++++++++++++++++ .../AITutor/AIFeaturedCoursesListing.tsx | 98 +++++++++++++++++++ src/components/AITutor/AITutorLayout.tsx | 17 ++++ src/components/AITutor/AITutorSidebar.tsx | 20 +++- src/components/GenerateCourse/AICourse.tsx | 8 +- .../GenerateCourse/AICourseCard.tsx | 10 +- src/pages/ai/courses.astro | 21 ++++ src/pages/ai/explore.astro | 21 ++++ src/pages/ai/index.astro | 13 +-- src/pages/ai/stuff-picks.astro | 21 ++++ src/queries/ai-course.ts | 48 ++++++++- 11 files changed, 351 insertions(+), 22 deletions(-) create mode 100644 src/components/AITutor/AIExploreCourseListing.tsx create mode 100644 src/components/AITutor/AIFeaturedCoursesListing.tsx create mode 100644 src/components/AITutor/AITutorLayout.tsx create mode 100644 src/pages/ai/courses.astro create mode 100644 src/pages/ai/explore.astro create mode 100644 src/pages/ai/stuff-picks.astro 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 ( + <> + <div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1"> + <div className="flex items-center gap-2"> + <h2 className="text-lg font-semibold">Explore Courses</h2> + </div> + </div> + + {(isExploreAiCoursesLoading || isInitialLoading) && ( + <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> + <Loader2 + className="size-4 animate-spin text-gray-400" + strokeWidth={2.5} + /> + <p className="text-sm font-medium text-gray-600">Loading...</p> + </div> + )} + + {error && !isExploreAiCoursesLoading && !isInitialLoading && ( + <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> + <AlertCircle className="size-4 text-red-500" /> + <p className="text-sm font-medium text-red-600"> + {error?.message ?? 'Error loading courses.'} + </p> + </div> + )} + + {!isExploreAiCoursesLoading && + courses && + courses.length > 0 && + !error && ( + <div className="flex flex-col gap-2"> + {courses.map((course) => ( + <AICourseCard + key={course._id} + course={course} + showActions={false} + showProgress={false} + /> + ))} + </div> + )} + + {hasNextPage && !isFetchingNextPage && !error && ( + <div className="mt-4 flex items-center justify-center"> + <button + onClick={() => fetchNextPage()} + disabled={isFetchingNextPage} + className="rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:opacity-50" + > + Load more + </button> + </div> + )} + + {isFetchingNextPage && !error && ( + <div className="mt-4 flex items-center justify-center gap-2"> + <Loader2 + className="size-4 animate-spin text-gray-400" + strokeWidth={2.5} + /> + <p className="text-sm font-medium text-gray-600"> + Loading more courses... + </p> + </div> + )} + </> + ); +} 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<ListUserAiCoursesQuery>({ + 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 ( + <> + <div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1"> + <div className="flex items-center gap-2"> + <h2 className="text-lg font-semibold">Stuff Picks</h2> + </div> + </div> + + {(isUserAiCoursesLoading || isInitialLoading) && ( + <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> + <Loader2 + className="size-4 animate-spin text-gray-400" + strokeWidth={2.5} + /> + <p className="text-sm font-medium text-gray-600">Loading...</p> + </div> + )} + + {!isUserAiCoursesLoading && courses && courses.length > 0 && ( + <div className="flex flex-col gap-2"> + {courses.map((course) => ( + <AICourseCard + key={course._id} + course={course} + showActions={false} + showProgress={false} + /> + ))} + </div> + )} + + {!isUserAiCoursesLoading && + (userAiCourses?.data?.length || 0 > 0) && + courses.length === 0 && ( + <div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> + <p className="text-sm text-gray-600"> + No courses match your search. + </p> + </div> + )} + </> + ); +} 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 ( + <div className="flex flex-grow flex-row"> + <AITutorSidebar activeTab={activeTab} /> + <div className="flex flex-grow flex-col">{children}</div> + </div> + ); +} 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 </label> <div className="relative"> - <div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"> + <div className="absolute top-1/2 left-3 -translate-y-1/2 text-gray-400"> <SearchIcon size={18} /> </div> <input @@ -107,7 +107,7 @@ export function AICourse(props: AICourseProps) { onChange={(e) => 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} /> </div> @@ -162,10 +162,6 @@ export function AICourse(props: AICourseProps) { </button> </form> </div> - - <div className="mt-8 min-h-[200px]"> - <UserCoursesList /> - </div> </div> </section> ); 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) { <span>{totalTopics} lessons</span> </div> - {totalTopics > 0 && ( + {showProgress && totalTopics > 0 && ( <div className="flex items-center"> <div className="mr-2 h-1.5 w-16 overflow-hidden rounded-full bg-gray-200"> <div @@ -72,8 +74,8 @@ export function AICourseCard(props: AICourseCardProps) { </div> </a> - {course.slug && ( - <div className="absolute right-2 top-2"> + {showActions && course.slug && ( + <div className="absolute top-2 right-2"> <AICourseActions courseSlug={course.slug} /> </div> )} 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'; +--- + +<SkeletonLayout + title='Roadmap AI' + noIndex={true} + 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.' +> + <AITutorLayout activeTab='courses' client:load> + <section class='flex grow flex-col bg-gray-100'> + <div class='container mx-auto flex max-w-3xl flex-col py-10 max-sm:py-4'> + <UserCoursesList client:load /> + </div> + </section> + </AITutorLayout> +</SkeletonLayout> 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'; +--- + +<SkeletonLayout + title='Roadmap AI' + noIndex={true} + 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.' +> + <AITutorLayout activeTab='explore' client:load> + <section class='flex grow flex-col bg-gray-100'> + <div class='container mx-auto flex max-w-3xl flex-col py-10 max-sm:py-4'> + <AIExploreCourseListing client:load /> + </div> + </section> + </AITutorLayout> +</SkeletonLayout> 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.' > - <div class='flex flex-grow flex-row'> - <AITutorSidebar client:load activeTab='new' /> - <div class='flex flex-grow flex-col'> - <AICourse client:load /> - <CheckSubscriptionVerification client:load /> - </div> - </div> + <AITutorLayout activeTab='new' client:load> + <AICourse client:load /> + <CheckSubscriptionVerification client:load /> + </AITutorLayout> </SkeletonLayout> 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'; +--- + +<SkeletonLayout + title='Roadmap AI' + noIndex={true} + 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.' +> + <AITutorLayout activeTab='stuff-picks' client:load> + <section class='flex grow flex-col bg-gray-100'> + <div class='container mx-auto flex max-w-3xl flex-col py-10 max-sm:py-4'> + <AIFeaturedCoursesListing client:load /> + </div> + </section> + </AITutorLayout> +</SkeletonLayout> 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<ListExploreAiCoursesResponse>( + `/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, + ); +} From 69ed5d79de5bfe277ab64125aefcc0c3a47e3dbc Mon Sep 17 00:00:00 2001 From: Arik Chakma <arikchangma@gmail.com> Date: Wed, 9 Apr 2025 01:47:53 +0600 Subject: [PATCH 03/31] wip --- .../AITutor/AIFeaturedCoursesListing.tsx | 39 +++++++++++-------- src/queries/ai-course.ts | 32 +++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/components/AITutor/AIFeaturedCoursesListing.tsx b/src/components/AITutor/AIFeaturedCoursesListing.tsx index b65cd022f..a5df649ed 100644 --- a/src/components/AITutor/AIFeaturedCoursesListing.tsx +++ b/src/components/AITutor/AIFeaturedCoursesListing.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { + listFeaturedAiCoursesOptions, listUserAiCoursesOptions, type ListUserAiCoursesQuery, } from '../../queries/ai-course'; @@ -8,6 +9,7 @@ import { useEffect, useState } from 'react'; import { Loader2 } from 'lucide-react'; import { getUrlParams, setUrlParams, deleteUrlParam } from '../../lib/browser'; import { AICourseCard } from '../GenerateCourse/AICourseCard'; +import { Pagination } from '../Pagination/Pagination'; type AIFeaturedCoursesListingProps = {}; @@ -15,21 +17,18 @@ export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { const [isInitialLoading, setIsInitialLoading] = useState(true); const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({ - perPage: '10', + perPage: '20', currPage: '1', - query: '', }); - const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery( - listUserAiCoursesOptions(pageState), - queryClient, - ); + const { data: featuredAiCourses, isFetching: isFeaturedAiCoursesLoading } = + useQuery(listFeaturedAiCoursesOptions(pageState), queryClient); useEffect(() => { setIsInitialLoading(false); - }, [userAiCourses]); + }, [featuredAiCourses]); - const courses = userAiCourses?.data ?? []; + const courses = featuredAiCourses?.data ?? []; useEffect(() => { const queryParams = getUrlParams(); @@ -37,19 +36,16 @@ export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { setPageState({ ...pageState, currPage: queryParams?.p || '1', - query: queryParams?.q || '', }); }, []); useEffect(() => { - if (pageState?.currPage !== '1' || pageState?.query !== '') { + if (pageState?.currPage !== '1') { setUrlParams({ p: pageState?.currPage || '1', - q: pageState?.query || '', }); } else { deleteUrlParam('p'); - deleteUrlParam('q'); } }, [pageState]); @@ -61,7 +57,7 @@ export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { </div> </div> - {(isUserAiCoursesLoading || isInitialLoading) && ( + {(isFeaturedAiCoursesLoading || isInitialLoading) && ( <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> <Loader2 className="size-4 animate-spin text-gray-400" @@ -71,7 +67,7 @@ export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { </div> )} - {!isUserAiCoursesLoading && courses && courses.length > 0 && ( + {!isFeaturedAiCoursesLoading && courses && courses.length > 0 && ( <div className="flex flex-col gap-2"> {courses.map((course) => ( <AICourseCard @@ -81,11 +77,22 @@ export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { showProgress={false} /> ))} + + <Pagination + totalCount={featuredAiCourses?.totalCount || 0} + totalPages={featuredAiCourses?.totalPages || 0} + currPage={Number(featuredAiCourses?.currPage || 1)} + perPage={Number(featuredAiCourses?.perPage || 10)} + onPageChange={(page) => { + setPageState({ ...pageState, currPage: String(page) }); + }} + className="rounded-lg border border-gray-200 bg-white p-4" + /> </div> )} - {!isUserAiCoursesLoading && - (userAiCourses?.data?.length || 0 > 0) && + {!isFeaturedAiCoursesLoading && + (featuredAiCourses?.data?.length || 0 > 0) && courses.length === 0 && ( <div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> <p className="text-sm text-gray-600"> diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts index 0ab1a601c..4f90e2081 100644 --- a/src/queries/ai-course.ts +++ b/src/queries/ai-course.ts @@ -101,6 +101,38 @@ export function listUserAiCoursesOptions( }; } +type ListFeaturedAiCoursesParams = {}; + +type ListFeaturedAiCoursesQuery = { + perPage?: string; + currPage?: string; +}; + +type ListFeaturedAiCoursesResponse = { + data: AICourseWithLessonCount[]; + totalCount: number; + totalPages: number; + currPage: number; + perPage: number; +}; + +export function listFeaturedAiCoursesOptions( + params: ListFeaturedAiCoursesQuery = { + perPage: '10', + currPage: '1', + }, +) { + return { + queryKey: ['featured-ai-courses', params], + queryFn: () => { + return httpGet<ListFeaturedAiCoursesResponse>( + `/v1-list-featured-ai-courses`, + params, + ); + }, + }; +} + type ListExploreAiCoursesParams = {}; type ListExploreAiCoursesQuery = { From d1208047a5b71a80237e9235438b24687275ed72 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Wed, 9 Apr 2025 12:44:25 +0100 Subject: [PATCH 04/31] Fix mistakes and refacctor --- src/components/AITutor/AIExploreCourseListing.tsx | 2 +- src/components/AITutor/AITutorSidebar.tsx | 6 +++--- src/components/GenerateCourse/AICourseCard.tsx | 2 +- src/pages/ai/explore.astro | 2 +- src/pages/ai/{stuff-picks.astro => staff-picks.astro} | 0 5 files changed, 6 insertions(+), 6 deletions(-) rename src/pages/ai/{stuff-picks.astro => staff-picks.astro} (100%) diff --git a/src/components/AITutor/AIExploreCourseListing.tsx b/src/components/AITutor/AIExploreCourseListing.tsx index 65b57f1b2..4f132e871 100644 --- a/src/components/AITutor/AIExploreCourseListing.tsx +++ b/src/components/AITutor/AIExploreCourseListing.tsx @@ -56,7 +56,7 @@ export function AIExploreCourseListing(props: AIExploreCourseListingProps) { courses && courses.length > 0 && !error && ( - <div className="flex flex-col gap-2"> + <div className="grid grid-cols-2 gap-2"> {courses.map((course) => ( <AICourseCard key={course._id} diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index 2d1f15fe0..7ebc5006e 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -24,9 +24,9 @@ const sidebarItems = [ icon: BookOpen, }, { - key: 'stuff-picks', - label: 'Stuff Picks', - href: '/ai/stuff-picks', + key: 'staff-picks', + label: 'Staff Picks', + href: '/ai/staff-picks', icon: CircleDotIcon, }, { diff --git a/src/components/GenerateCourse/AICourseCard.tsx b/src/components/GenerateCourse/AICourseCard.tsx index 12e4bf09b..0d758978b 100644 --- a/src/components/GenerateCourse/AICourseCard.tsx +++ b/src/components/GenerateCourse/AICourseCard.tsx @@ -38,7 +38,7 @@ export function AICourseCard(props: AICourseCardProps) { <div className="relative"> <a href={`/ai/${course.slug}`} - className="hover:border-gray-3 00 group relative flex w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:bg-gray-50" + className="hover:border-gray-3 00 group relative flex w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:bg-gray-50 min-h-full " > <div className="flex items-center justify-between"> <span diff --git a/src/pages/ai/explore.astro b/src/pages/ai/explore.astro index 60f0e352a..b743e98af 100644 --- a/src/pages/ai/explore.astro +++ b/src/pages/ai/explore.astro @@ -13,7 +13,7 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; > <AITutorLayout activeTab='explore' client:load> <section class='flex grow flex-col bg-gray-100'> - <div class='container mx-auto flex max-w-3xl flex-col py-10 max-sm:py-4'> + <div class='mx-auto w-full flex max-w-4xl flex-col py-10 max-sm:py-4'> <AIExploreCourseListing client:load /> </div> </section> diff --git a/src/pages/ai/stuff-picks.astro b/src/pages/ai/staff-picks.astro similarity index 100% rename from src/pages/ai/stuff-picks.astro rename to src/pages/ai/staff-picks.astro From d0f8fc4e6af06e5b65967bfd3f65cf1e6e25a323 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Wed, 9 Apr 2025 14:18:02 +0100 Subject: [PATCH 05/31] AI landing page changes --- src/components/AITutor/AILoadingState.tsx | 27 +++ src/components/AITutor/AITutorLayout.tsx | 4 +- src/components/AITutor/AITutorTallMessage.tsx | 31 ++++ src/components/GenerateCourse/AICourse.tsx | 175 +++++++++--------- .../GenerateCourse/AICourseCard.tsx | 12 +- .../GenerateCourse/UserCoursesList.tsx | 86 +++++---- src/pages/ai/courses.astro | 6 +- 7 files changed, 196 insertions(+), 145 deletions(-) create mode 100644 src/components/AITutor/AILoadingState.tsx create mode 100644 src/components/AITutor/AITutorTallMessage.tsx diff --git a/src/components/AITutor/AILoadingState.tsx b/src/components/AITutor/AILoadingState.tsx new file mode 100644 index 000000000..66ed9de4e --- /dev/null +++ b/src/components/AITutor/AILoadingState.tsx @@ -0,0 +1,27 @@ +import { Loader2 } from 'lucide-react'; + +type AILoadingStateProps = { + title: string; + subtitle?: string; +}; + +export function AILoadingState(props: AILoadingStateProps) { + const { title, subtitle } = props; + + return ( + <div className="flex min-h-full w-full flex-col items-center justify-center gap-4 rounded-lg border border-gray-200 bg-white p-8"> + <div className="relative"> + <Loader2 className="size-12 animate-spin text-gray-300" /> + <div className="absolute inset-0 flex items-center justify-center"> + <div className="size-4 rounded-full bg-white"></div> + </div> + </div> + <div className="text-center"> + <p className="text-lg font-medium text-gray-900">{title}</p> + {subtitle && ( + <p className="mt-1 text-sm text-gray-500">{subtitle}</p> + )} + </div> + </div> + ); +} \ No newline at end of file diff --git a/src/components/AITutor/AITutorLayout.tsx b/src/components/AITutor/AITutorLayout.tsx index 18b23649f..5cfe7f3ce 100644 --- a/src/components/AITutor/AITutorLayout.tsx +++ b/src/components/AITutor/AITutorLayout.tsx @@ -11,7 +11,9 @@ export function AITutorLayout(props: AITutorLayoutProps) { return ( <div className="flex flex-grow flex-row"> <AITutorSidebar activeTab={activeTab} /> - <div className="flex flex-grow flex-col">{children}</div> + <div className="flex flex-grow flex-col bg-gray-100 px-4 py-4"> + {children} + </div> </div> ); } diff --git a/src/components/AITutor/AITutorTallMessage.tsx b/src/components/AITutor/AITutorTallMessage.tsx new file mode 100644 index 000000000..a0000a3ff --- /dev/null +++ b/src/components/AITutor/AITutorTallMessage.tsx @@ -0,0 +1,31 @@ +import { type LucideIcon } from 'lucide-react'; + +type AITutorTallMessageProps = { + title: string; + subtitle?: string; + icon: LucideIcon; + buttonText?: string; + onButtonClick?: () => void; +}; + +export function AITutorTallMessage(props: AITutorTallMessageProps) { + const { title, subtitle, icon: Icon, buttonText, onButtonClick } = props; + + return ( + <div className="flex min-h-full flex-grow flex-col items-center justify-center rounded-lg"> + <Icon className="size-12 text-gray-300" /> + <div className="my-4 text-center"> + <h2 className="mb-2 text-xl font-semibold">{title}</h2> + {subtitle && <p className="text-base text-gray-600">{subtitle}</p>} + </div> + {buttonText && onButtonClick && ( + <button + onClick={onButtonClick} + className="rounded-lg bg-black px-4 py-2 text-sm text-white hover:opacity-80" + > + {buttonText} + </button> + )} + </div> + ); +} diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index 5708a505c..428fb21fe 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from 'react'; import { cn } from '../../lib/classname'; import { isLoggedIn } from '../../lib/jwt'; import { showLoginPopup } from '../../lib/popup'; -import { UserCoursesList } from './UserCoursesList'; import { FineTuneCourse } from './FineTuneCourse'; import { clearFineTuneData, @@ -72,97 +71,95 @@ export function AICourse(props: AICourseProps) { } return ( - <section className="flex grow flex-col bg-gray-100"> - <div className="container mx-auto flex max-w-3xl flex-col py-24 max-sm:py-4"> - <h1 className="mb-2.5 text-center text-4xl font-bold max-sm:mb-2 max-sm:text-left max-sm:text-xl"> - Learn anything with AI - </h1> - <p className="mb-6 text-center text-lg text-gray-600 max-sm:hidden max-sm:text-left max-sm:text-sm"> - Enter a topic below to generate a personalized course for it - </p> - - <div className="rounded-lg border border-gray-200 bg-white p-6 max-sm:p-4"> - <form - className="flex flex-col gap-5" - onSubmit={(e) => { - e.preventDefault(); - onSubmit(); - }} - > - <div className="flex flex-col"> - <label - htmlFor="keyword" - className="mb-2.5 text-sm font-medium text-gray-700" - > - Course Topic - </label> - <div className="relative"> - <div className="absolute top-1/2 left-3 -translate-y-1/2 text-gray-400"> - <SearchIcon size={18} /> - </div> - <input - id="keyword" - type="text" - value={keyword} - onChange={(e) => 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:ring-1 focus:ring-gray-500 focus:outline-hidden max-sm:placeholder:text-base" - maxLength={50} - /> + <div className="flex w-full max-w-3xl mx-auto flex-grow flex-col justify-center"> + <h1 className="mb-2.5 text-center text-4xl font-bold max-sm:mb-2 max-sm:text-left max-sm:text-xl"> + What can I help you learn? + </h1> + <p className="mb-6 text-center text-lg text-gray-600 max-sm:hidden max-sm:text-left max-sm:text-sm"> + Enter a topic below to generate a personalized course for it + </p> + + <div className="rounded-lg border border-gray-200 bg-white p-6 max-sm:p-4"> + <form + className="flex flex-col gap-5" + onSubmit={(e) => { + e.preventDefault(); + onSubmit(); + }} + > + <div className="flex flex-col"> + <label + htmlFor="keyword" + className="mb-2.5 text-sm font-medium text-gray-700" + > + Course Topic + </label> + <div className="relative"> + <div className="absolute top-1/2 left-3 -translate-y-1/2 text-gray-400"> + <SearchIcon size={18} /> </div> + <input + id="keyword" + type="text" + value={keyword} + onChange={(e) => 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:ring-1 focus:ring-gray-500 focus:outline-hidden max-sm:placeholder:text-base" + maxLength={50} + /> </div> - - <div className="flex flex-col"> - <label className="mb-2.5 text-sm font-medium text-gray-700"> - Difficulty Level - </label> - <div className="flex gap-2 max-sm:flex-col max-sm:gap-1"> - {difficultyLevels.map((level) => ( - <button - key={level} - type="button" - onClick={() => setDifficulty(level)} - className={cn( - 'rounded-md border px-4 py-2 capitalize max-sm:text-sm', - difficulty === level - ? 'border-gray-800 bg-gray-800 text-white' - : 'border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-200', - )} - > - {level} - </button> - ))} - </div> + </div> + + <div className="flex flex-col"> + <label className="mb-2.5 text-sm font-medium text-gray-700"> + Difficulty Level + </label> + <div className="flex gap-2 max-sm:flex-col max-sm:gap-1"> + {difficultyLevels.map((level) => ( + <button + key={level} + type="button" + onClick={() => setDifficulty(level)} + className={cn( + 'rounded-md border px-4 py-2 capitalize max-sm:text-sm', + difficulty === level + ? 'border-gray-800 bg-gray-800 text-white' + : 'border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-200', + )} + > + {level} + </button> + ))} </div> - - <FineTuneCourse - hasFineTuneData={hasFineTuneData} - setHasFineTuneData={setHasFineTuneData} - about={about} - goal={goal} - customInstructions={customInstructions} - setAbout={setAbout} - setGoal={setGoal} - setCustomInstructions={setCustomInstructions} - /> - - <button - type="submit" - disabled={!keyword.trim()} - className={cn( - 'mt-2 flex items-center justify-center rounded-md px-4 py-2 font-medium text-white transition-colors max-sm:text-sm', - !keyword.trim() - ? 'cursor-not-allowed bg-gray-400' - : 'bg-black hover:bg-gray-800', - )} - > - <WandIcon size={18} className="mr-2" /> - Generate Course - </button> - </form> - </div> + </div> + + <FineTuneCourse + hasFineTuneData={hasFineTuneData} + setHasFineTuneData={setHasFineTuneData} + about={about} + goal={goal} + customInstructions={customInstructions} + setAbout={setAbout} + setGoal={setGoal} + setCustomInstructions={setCustomInstructions} + /> + + <button + type="submit" + disabled={!keyword.trim()} + className={cn( + 'mt-2 flex items-center justify-center rounded-md px-4 py-2 font-medium text-white transition-colors max-sm:text-sm', + !keyword.trim() + ? 'cursor-not-allowed bg-gray-400' + : 'bg-black hover:bg-gray-800', + )} + > + <WandIcon size={18} className="mr-2" /> + Generate Course + </button> + </form> </div> - </section> + </div> ); } diff --git a/src/components/GenerateCourse/AICourseCard.tsx b/src/components/GenerateCourse/AICourseCard.tsx index 0d758978b..9db197881 100644 --- a/src/components/GenerateCourse/AICourseCard.tsx +++ b/src/components/GenerateCourse/AICourseCard.tsx @@ -12,14 +12,6 @@ type AICourseCardProps = { export function AICourseCard(props: AICourseCardProps) { const { course, showActions = true, showProgress = true } = props; - // Format date if available - const formattedDate = course.createdAt - ? new Date(course.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }) - : null; - // Map difficulty to color const difficultyColor = { @@ -35,10 +27,10 @@ export function AICourseCard(props: AICourseCardProps) { totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0; return ( - <div className="relative"> + <div className="relative flex flex-grow flex-col"> <a href={`/ai/${course.slug}`} - className="hover:border-gray-3 00 group relative flex w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:bg-gray-50 min-h-full " + className="hover:border-gray-3 00 group relative flex h-full min-h-[140px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:bg-gray-50" > <div className="flex items-center justify-between"> <span diff --git a/src/components/GenerateCourse/UserCoursesList.tsx b/src/components/GenerateCourse/UserCoursesList.tsx index 798de8d5f..ee56d665a 100644 --- a/src/components/GenerateCourse/UserCoursesList.tsx +++ b/src/components/GenerateCourse/UserCoursesList.tsx @@ -7,7 +7,7 @@ import { import { queryClient } from '../../stores/query-client'; import { AICourseCard } from './AICourseCard'; import { useEffect, useState } from 'react'; -import { Gift, Loader2, User2 } from 'lucide-react'; +import { BookOpen, Gift } from 'lucide-react'; import { isLoggedIn } from '../../lib/jwt'; import { showLoginPopup } from '../../lib/popup'; import { cn } from '../../lib/classname'; @@ -16,6 +16,8 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { getUrlParams, setUrlParams, deleteUrlParam } from '../../lib/browser'; import { AICourseSearch } from './AICourseSearch'; import { Pagination } from '../Pagination/Pagination'; +import { AILoadingState } from '../AITutor/AILoadingState'; +import { AITutorTallMessage } from '../AITutor/AITutorTallMessage'; type UserCoursesListProps = {}; @@ -72,6 +74,43 @@ export function UserCoursesList(props: UserCoursesListProps) { } }, [pageState]); + if (isInitialLoading || isUserAiCoursesLoading) { + return ( + <AILoadingState + title="Loading your courses" + subtitle="This may take a moment..." + /> + ); + } + + if (!isLoggedIn()) { + return ( + <AITutorTallMessage + title="Sign up or login" + subtitle="Takes 2s to sign up and generate your first course." + icon={BookOpen} + buttonText="Sign up or Login" + onButtonClick={() => { + showLoginPopup(); + }} + /> + ); + } + + if (courses.length === 0) { + return ( + <AITutorTallMessage + title="No courses found" + subtitle="You haven't generated any courses yet." + icon={BookOpen} + buttonText="Create your first course" + onButtonClick={() => { + window.location.href = '/ai'; + }} + /> + ); + } + return ( <> {showUpgradePopup && ( @@ -105,7 +144,7 @@ export function UserCoursesList(props: UserCoursesListProps) { onClick={() => { setShowUpgradePopup(true); }} - className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pl-1.5 pr-2 text-xs text-white" + className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pr-2 pl-1.5 text-xs text-white" > <Gift className="size-4" /> Upgrade @@ -127,46 +166,13 @@ export function UserCoursesList(props: UserCoursesListProps) { </div> </div> - {!isInitialLoading && !isUserAiCoursesLoading && !isAuthenticated && ( - <div className="flex min-h-[152px] flex-col items-center justify-center rounded-lg border border-gray-200 bg-white px-6 py-4"> - <User2 className="mb-2 size-8 text-gray-300" /> - <p className="max-w-sm text-balance text-center text-gray-500"> - <button - onClick={() => { - showLoginPopup(); - }} - className="font-medium text-black underline underline-offset-2 hover:opacity-80" - > - Sign up (free and takes 2s) or login - </button>{' '} - to generate and save courses. - </p> - </div> - )} - - {!isUserAiCoursesLoading && !isInitialLoading && courses.length === 0 && isAuthenticated && ( - <div className="flex min-h-[152px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> - <p className="text-sm text-gray-600"> - You haven't generated any courses yet. - </p> - </div> - )} - - {(isUserAiCoursesLoading || isInitialLoading) && ( - <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> - <Loader2 - className="size-4 animate-spin text-gray-400" - strokeWidth={2.5} - /> - <p className="text-sm font-medium text-gray-600">Loading...</p> - </div> - )} - {!isUserAiCoursesLoading && courses && courses.length > 0 && ( <div className="flex flex-col gap-2"> - {courses.map((course) => ( - <AICourseCard key={course._id} course={course} /> - ))} + <div className="grid grid-cols-3 gap-2"> + {courses.map((course) => ( + <AICourseCard key={course._id} course={course} /> + ))} + </div> <Pagination totalCount={userAiCourses?.totalCount || 0} diff --git a/src/pages/ai/courses.astro b/src/pages/ai/courses.astro index e54254731..25a37a443 100644 --- a/src/pages/ai/courses.astro +++ b/src/pages/ai/courses.astro @@ -12,10 +12,6 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.' > <AITutorLayout activeTab='courses' client:load> - <section class='flex grow flex-col bg-gray-100'> - <div class='container mx-auto flex max-w-3xl flex-col py-10 max-sm:py-4'> - <UserCoursesList client:load /> - </div> - </section> + <UserCoursesList client:load /> </AITutorLayout> </SkeletonLayout> From bbe716cecf0262f358b87f6f4ac75e47f7ba0bcb Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Wed, 9 Apr 2025 14:36:08 +0100 Subject: [PATCH 06/31] Update sidebar design --- src/components/AITutor/AITutorSidebar.tsx | 56 ++++++++++------------- src/pages/ai/staff-picks.astro | 2 +- 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index 7ebc5006e..0f4efaf2a 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -1,10 +1,4 @@ -import { - ChevronLeft, - PlusCircle, - BookOpen, - Compass, - CircleDotIcon, -} from 'lucide-react'; +import { BookOpen, Bot, Compass, Plus, Star } from 'lucide-react'; type AITutorSidebarProps = { activeTab: AITutorTab; @@ -15,7 +9,7 @@ const sidebarItems = [ key: 'new', label: 'New Course', href: '/ai', - icon: PlusCircle, + icon: Plus, }, { key: 'courses', @@ -27,7 +21,7 @@ const sidebarItems = [ key: 'staff-picks', label: 'Staff Picks', href: '/ai/staff-picks', - icon: CircleDotIcon, + icon: Star, }, { key: 'explore', @@ -43,38 +37,34 @@ export function AITutorSidebar(props: AITutorSidebarProps) { const { activeTab } = props; return ( - <div className="flex w-[240px] flex-col border-r border-gray-200 bg-gradient-to-b from-white to-gray-50"> - <a - href="https://roadmap.sh" - className="flex w-full items-center justify-start gap-1.5 border-b border-gray-200 px-5 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-black" - > - <ChevronLeft className="size-4" /> - Back to <span className="font-semibold text-black">roadmap.sh</span> - </a> - - <div className="px-6 pt-6 pb-2"> - <h2 className="text-lg font-semibold text-gray-900">Learn with AI</h2> - <p className="mt-1 text-sm text-gray-500"> + <aside className="hidden w-[255px] shrink-0 border-r border-slate-200 md:block"> + <div className="flex flex-col items-start justify-center px-6 py-5"> + <Bot className="mb-2 size-8 text-black" /> + <h2 className="mb-0.5 text-base font-semibold text-black">AI Tutor</h2> + <p className="max-w-[150px] text-xs text-gray-500"> Your personalized learning companion for any topic </p> </div> - <div className="flex-1 px-3 py-3"> - <nav className="space-y-1"> - {sidebarItems.map((item) => ( + <ul className="space-y-1"> + {sidebarItems.map((item) => ( + <li key={item.key}> <a - key={item.key} href={item.href} - className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-700 transition-all hover:bg-gray-100 hover:text-black ${ - activeTab === item.key ? 'bg-gray-100 text-black' : '' + className={`font-regular flex w-full items-center border-r-2 px-5 py-2 text-sm transition-all ${ + activeTab === item.key + ? 'border-r-black bg-gray-100 text-black' + : 'border-r-transparent text-gray-500 hover:border-r-gray-300' }`} > - <item.icon className="size-4" /> - {item.label} + <span className="flex grow items-center"> + <item.icon className="mr-2 size-4" /> + {item.label} + </span> </a> - ))} - </nav> - </div> - </div> + </li> + ))} + </ul> + </aside> ); } diff --git a/src/pages/ai/staff-picks.astro b/src/pages/ai/staff-picks.astro index ad7ad5f74..840680333 100644 --- a/src/pages/ai/staff-picks.astro +++ b/src/pages/ai/staff-picks.astro @@ -11,7 +11,7 @@ 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.' > - <AITutorLayout activeTab='stuff-picks' client:load> + <AITutorLayout activeTab='staff-picks' client:load> <section class='flex grow flex-col bg-gray-100'> <div class='container mx-auto flex max-w-3xl flex-col py-10 max-sm:py-4'> <AIFeaturedCoursesListing client:load /> From 4cf3f052f76d04c8ac061f715260be4e967737f1 Mon Sep 17 00:00:00 2001 From: Arik Chakma <arikchangma@gmail.com> Date: Wed, 9 Apr 2025 21:11:41 +0600 Subject: [PATCH 07/31] wip --- .../GenerateCourse/AICourseContent.tsx | 52 ++++++++++-- .../GenerateCourse/AICourseOutlineHeader.tsx | 34 ++++++-- .../GenerateCourse/AICourseOutlineView.tsx | 6 ++ .../GenerateCourse/ForkCourseAlert.tsx | 34 ++++++++ .../GenerateCourse/ForkCourseConfirmation.tsx | 84 +++++++++++++++++++ src/components/GenerateCourse/GetAICourse.tsx | 1 + .../GenerateCourse/RegenerateOutline.tsx | 30 +++++-- 7 files changed, 219 insertions(+), 22 deletions(-) create mode 100644 src/components/GenerateCourse/ForkCourseAlert.tsx create mode 100644 src/components/GenerateCourse/ForkCourseConfirmation.tsx diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index e0364fe33..6dc8aeec6 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -5,8 +5,9 @@ import { CircleOff, Menu, X, - Map, MessageCircleOffIcon, - MessageCircleIcon + Map, + MessageCircleOffIcon, + MessageCircleIcon, } from 'lucide-react'; import { useEffect, useState } from 'react'; import { type AiCourse } from '../../lib/ai'; @@ -21,6 +22,9 @@ import { AILimitsPopup } from './AILimitsPopup'; import { AICourseOutlineView } from './AICourseOutlineView'; import { AICourseRoadmapView } from './AICourseRoadmapView'; import { AICourseFooter } from './AICourseFooter'; +import { ForkCourseAlert } from './ForkCourseAlert'; +import { ForkCourseConfirmation } from './ForkCourseConfirmation'; +import { useAuth } from '../../hooks/use-auth'; type AICourseContentProps = { courseSlug?: string; @@ -28,12 +32,20 @@ type AICourseContentProps = { isLoading: boolean; error?: string; onRegenerateOutline: (prompt?: string) => void; + creatorId?: string; }; export type AICourseViewMode = 'module' | 'outline' | 'roadmap'; export function AICourseContent(props: AICourseContentProps) { - const { course, courseSlug, isLoading, error, onRegenerateOutline } = props; + const { + course, + courseSlug, + isLoading, + error, + onRegenerateOutline, + creatorId, + } = props; const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); @@ -43,8 +55,10 @@ export function AICourseContent(props: AICourseContentProps) { const [activeLessonIndex, setActiveLessonIndex] = useState(0); const [sidebarOpen, setSidebarOpen] = useState(false); const [viewMode, setViewMode] = useState<AICourseViewMode>('outline'); + const [isForkingCourse, setIsForkingCourse] = useState(false); const { isPaidUser } = useIsPaidUser(); + const currentUser = useAuth(); const aiCourseProgress = course.done || []; @@ -202,7 +216,7 @@ export function AICourseContent(props: AICourseContentProps) { <div className="my-5"> <a href="/ai" - className="rounded-md bg-black px-6 py-2 text-sm font-medium text-white hover:bg-opacity-80" + className="hover:bg-opacity-80 rounded-md bg-black px-6 py-2 text-sm font-medium text-white" > Create a course with AI </a> @@ -214,6 +228,7 @@ export function AICourseContent(props: AICourseContentProps) { } const isViewingLesson = viewMode === 'module'; + const isForkable = !!currentUser?.id && currentUser.id !== creatorId; return ( <section className="flex h-screen grow flex-col overflow-hidden bg-gray-50"> @@ -272,7 +287,7 @@ export function AICourseContent(props: AICourseContentProps) { <header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 max-lg:py-4 lg:h-[80px]"> <div className="flex items-center"> <div className="flex flex-col"> - <h1 className="text-balance text-xl font-bold leading-tight! text-gray-900 max-lg:mb-0.5 max-lg:text-lg"> + <h1 className="text-xl leading-tight! font-bold text-balance text-gray-900 max-lg:mb-0.5 max-lg:text-lg"> {course.title || 'Loading Course...'} </h1> <div className="mt-1 flex flex-row items-center gap-2 text-sm text-gray-600 max-lg:text-xs"> @@ -342,7 +357,7 @@ export function AICourseContent(props: AICourseContentProps) { width: `${finishedPercentage}%`, }} className={cn( - 'absolute bottom-0 left-0 top-0', + 'absolute top-0 bottom-0 left-0', 'bg-gray-200/50', )} ></span> @@ -420,6 +435,27 @@ export function AICourseContent(props: AICourseContentProps) { )} key={`${courseSlug}-${viewMode}`} > + {isForkable && + courseSlug && + (viewMode === 'outline' || viewMode === 'roadmap') && ( + <ForkCourseAlert + courseSlug={courseSlug} + creatorId={creatorId} + onForkCourse={() => { + setIsForkingCourse(true); + }} + /> + )} + + {isForkingCourse && ( + <ForkCourseConfirmation + onClose={() => { + setIsForkingCourse(false); + }} + courseSlug={courseSlug!} + /> + )} + {viewMode === 'module' && ( <AICourseLesson courseSlug={courseSlug!} @@ -450,6 +486,10 @@ export function AICourseContent(props: AICourseContentProps) { setViewMode={setViewMode} setExpandedModules={setExpandedModules} viewMode={viewMode} + isForkable={isForkable} + onForkCourse={() => { + setIsForkingCourse(true); + }} /> )} diff --git a/src/components/GenerateCourse/AICourseOutlineHeader.tsx b/src/components/GenerateCourse/AICourseOutlineHeader.tsx index a841c99bb..386efc7cb 100644 --- a/src/components/GenerateCourse/AICourseOutlineHeader.tsx +++ b/src/components/GenerateCourse/AICourseOutlineHeader.tsx @@ -10,11 +10,20 @@ type AICourseOutlineHeaderProps = { onRegenerateOutline: (prompt?: string) => void; viewMode: AICourseViewMode; setViewMode: (mode: AICourseViewMode) => void; + isForkable: boolean; + onForkCourse: () => void; }; export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) { - const { course, isLoading, onRegenerateOutline, viewMode, setViewMode } = - props; + const { + course, + isLoading, + onRegenerateOutline, + viewMode, + setViewMode, + isForkable, + onForkCourse, + } = props; return ( <div @@ -24,18 +33,22 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) { )} > <div className="max-lg:hidden"> - <h2 className="mb-1 text-balance text-2xl font-bold max-lg:text-lg max-lg:leading-tight"> + <h2 className="mb-1 text-2xl font-bold text-balance max-lg:text-lg max-lg:leading-tight"> {course.title || 'Loading course ..'} </h2> - <p className="text-sm capitalize text-gray-500"> + <p className="text-sm text-gray-500 capitalize"> {course.title ? course.difficulty : 'Please wait ..'} </p> </div> - <div className="absolute right-3 top-3 flex gap-2 max-lg:relative max-lg:right-0 max-lg:top-0 max-lg:w-full max-lg:flex-row-reverse max-lg:justify-between"> + <div className="absolute top-3 right-3 flex gap-2 max-lg:relative max-lg:top-0 max-lg:right-0 max-lg:w-full max-lg:flex-row-reverse max-lg:justify-between"> {!isLoading && ( <> - <RegenerateOutline onRegenerateOutline={onRegenerateOutline} /> + <RegenerateOutline + onRegenerateOutline={onRegenerateOutline} + isForkable={isForkable} + onForkCourse={onForkCourse} + /> <div className="mr-1 flex rounded-lg border border-gray-200 bg-white p-0.5"> <button onClick={() => setViewMode('outline')} @@ -55,7 +68,14 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) { <span>Outline</span> </button> <button - onClick={() => setViewMode('roadmap')} + onClick={() => { + if (isForkable) { + onForkCourse(); + return; + } + + setViewMode('roadmap'); + }} className={cn( 'flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors', viewMode === 'roadmap' diff --git a/src/components/GenerateCourse/AICourseOutlineView.tsx b/src/components/GenerateCourse/AICourseOutlineView.tsx index 6aef4db96..75680f832 100644 --- a/src/components/GenerateCourse/AICourseOutlineView.tsx +++ b/src/components/GenerateCourse/AICourseOutlineView.tsx @@ -17,6 +17,8 @@ type AICourseOutlineViewProps = { setViewMode: (mode: AICourseViewMode) => void; setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>; viewMode: AICourseViewMode; + isForkable: boolean; + onForkCourse: () => void; }; export function AICourseOutlineView(props: AICourseOutlineViewProps) { @@ -30,6 +32,8 @@ export function AICourseOutlineView(props: AICourseOutlineViewProps) { setViewMode, setExpandedModules, viewMode, + isForkable, + onForkCourse, } = props; const aiCourseProgress = course.done || []; @@ -42,6 +46,8 @@ export function AICourseOutlineView(props: AICourseOutlineViewProps) { onRegenerateOutline={onRegenerateOutline} viewMode={viewMode} setViewMode={setViewMode} + isForkable={isForkable} + onForkCourse={onForkCourse} /> {course.title ? ( <div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4"> diff --git a/src/components/GenerateCourse/ForkCourseAlert.tsx b/src/components/GenerateCourse/ForkCourseAlert.tsx new file mode 100644 index 000000000..e7280b94a --- /dev/null +++ b/src/components/GenerateCourse/ForkCourseAlert.tsx @@ -0,0 +1,34 @@ +import { GitForkIcon } from 'lucide-react'; +import { getUser } from '../../lib/jwt'; + +type ForkCourseAlertProps = { + courseSlug: string; + creatorId?: string; + onForkCourse: () => void; +}; + +export function ForkCourseAlert(props: ForkCourseAlertProps) { + const { courseSlug, creatorId, onForkCourse } = props; + + const currentUser = getUser(); + + if (!currentUser || !creatorId || currentUser?.id === creatorId) { + return null; + } + + return ( + <div className="mb-4 flex items-center justify-between gap-2 rounded-lg bg-yellow-200 p-3 text-black"> + <p className="text-sm text-balance"> + To start tracking your progress, you can fork the course. + </p> + + <button + className="flex shrink-0 items-center gap-2 rounded-md bg-yellow-400 p-1 px-2 text-sm text-black" + onClick={onForkCourse} + > + <GitForkIcon className="size-3.5" /> + Fork Course + </button> + </div> + ); +} diff --git a/src/components/GenerateCourse/ForkCourseConfirmation.tsx b/src/components/GenerateCourse/ForkCourseConfirmation.tsx new file mode 100644 index 000000000..b0c19f67b --- /dev/null +++ b/src/components/GenerateCourse/ForkCourseConfirmation.tsx @@ -0,0 +1,84 @@ +import { GitForkIcon, Loader2Icon } from 'lucide-react'; +import { Modal } from '../Modal'; +import type { AICourseDocument } from '../../queries/ai-course'; +import { useMutation } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; +import { httpPost } from '../../lib/query-http'; +import { useToast } from '../../hooks/use-toast'; +import { useState } from 'react'; + +type ForkAICourseParams = { + aiCourseSlug: string; +}; + +type ForkAICourseBody = {}; + +type ForkAICourseQuery = {}; + +type ForkAICourseResponse = AICourseDocument; + +type ForkCourseConfirmationProps = { + onClose: () => void; + courseSlug: string; +}; + +export function ForkCourseConfirmation(props: ForkCourseConfirmationProps) { + const { onClose, courseSlug } = props; + + const toast = useToast(); + const [isPending, setIsPending] = useState(false); + const { mutate: forkCourse } = useMutation( + { + mutationFn: async () => { + setIsPending(true); + return httpPost( + `${import.meta.env.PUBLIC_API_URL}/v1-fork-ai-course/${courseSlug}`, + {}, + ); + }, + onSuccess(data) { + window.location.href = `/ai/${data.slug}`; + }, + onError(error) { + toast.error(error?.message || 'Failed to fork course'); + setIsPending(false); + }, + }, + queryClient, + ); + + return ( + <Modal onClose={isPending ? () => {} : onClose}> + <div className="flex flex-col items-center p-4 pt-8"> + <GitForkIcon className="size-14 text-gray-500" /> + <p className="mt-2 text-xl font-medium">Fork Course</p> + <p className="mt-1 text-center text-balance text-gray-500"> + Forking this course will create a new course with the same content. + </p> + + <div className="mt-4 grid w-full grid-cols-2 gap-2"> + <button + disabled={isPending} + className="flex items-center justify-center gap-2 rounded-md bg-gray-100 p-2 hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50" + > + Cancel + </button> + + <button + disabled={isPending} + className="flex h-10 items-center justify-center gap-2 rounded-md bg-black p-2 text-white hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" + onClick={() => { + forkCourse(); + }} + > + {isPending ? ( + <Loader2Icon className="size-4 animate-spin" /> + ) : ( + 'Fork Course' + )} + </button> + </div> + </div> + </Modal> + ); +} diff --git a/src/components/GenerateCourse/GetAICourse.tsx b/src/components/GenerateCourse/GetAICourse.tsx index e9b1c98a3..dfe45de9f 100644 --- a/src/components/GenerateCourse/GetAICourse.tsx +++ b/src/components/GenerateCourse/GetAICourse.tsx @@ -102,6 +102,7 @@ export function GetAICourse(props: GetAICourseProps) { courseSlug={courseSlug} error={error} onRegenerateOutline={handleRegenerateCourse} + creatorId={aiCourse?.userId} /> ); } diff --git a/src/components/GenerateCourse/RegenerateOutline.tsx b/src/components/GenerateCourse/RegenerateOutline.tsx index 57634af2f..45005c4ef 100644 --- a/src/components/GenerateCourse/RegenerateOutline.tsx +++ b/src/components/GenerateCourse/RegenerateOutline.tsx @@ -7,10 +7,12 @@ import { ModifyCoursePrompt } from './ModifyCoursePrompt'; type RegenerateOutlineProps = { onRegenerateOutline: (prompt?: string) => void; + isForkable: boolean; + onForkCourse: () => void; }; export function RegenerateOutline(props: RegenerateOutlineProps) { - const { onRegenerateOutline } = props; + const { onRegenerateOutline, isForkable, onForkCourse } = props; const [isDropdownVisible, setIsDropdownVisible] = useState(false); const [showUpgradeModal, setShowUpgradeModal] = useState(false); @@ -35,27 +37,33 @@ export function RegenerateOutline(props: RegenerateOutlineProps) { onClose={() => setShowPromptModal(false)} onSubmit={(prompt) => { setShowPromptModal(false); + if (isForkable) { + onForkCourse(); + return; + } onRegenerateOutline(prompt); }} /> )} - <div ref={ref} className="flex relative items-stretch"> + <div ref={ref} className="relative flex items-stretch"> <button - className={cn( - 'rounded-md px-2.5 text-gray-400 hover:text-black', - { - 'text-black': isDropdownVisible, - }, - )} + className={cn('rounded-md px-2.5 text-gray-400 hover:text-black', { + 'text-black': isDropdownVisible, + })} onClick={() => setIsDropdownVisible(!isDropdownVisible)} > <PenSquare className="text-current" size={16} strokeWidth={2.5} /> </button> {isDropdownVisible && ( - <div className="absolute right-0 top-full translate-y-1 min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white shadow-md"> + <div className="absolute top-full right-0 min-w-[170px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md"> <button onClick={() => { + setIsDropdownVisible(false); + if (isForkable) { + onForkCourse(); + return; + } onRegenerateOutline(); }} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100" @@ -70,6 +78,10 @@ export function RegenerateOutline(props: RegenerateOutlineProps) { <button onClick={() => { setIsDropdownVisible(false); + if (isForkable) { + onForkCourse(); + return; + } setShowPromptModal(true); }} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100" From 653778b13d5b01d3daaaee69992386a534ac3096 Mon Sep 17 00:00:00 2001 From: Arik Chakma <arikchangma@gmail.com> Date: Wed, 9 Apr 2025 21:14:16 +0600 Subject: [PATCH 08/31] wip --- .../GenerateCourse/AICourseContent.tsx | 4 +++ .../GenerateCourse/AICourseLesson.tsx | 32 +++++++++++++------ .../GenerateCourse/ModifyCoursePrompt.tsx | 17 ++++++---- .../GenerateCourse/RegenerateLesson.tsx | 22 +++++++++++-- 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index 6dc8aeec6..e2abc32dc 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -472,6 +472,10 @@ export function AICourseContent(props: AICourseContentProps) { onUpgrade={() => setShowUpgradeModal(true)} isAIChatsOpen={isAIChatsOpen} setIsAIChatsOpen={setIsAIChatsOpen} + isForkable={isForkable} + onForkCourse={() => { + setIsForkingCourse(true); + }} /> )} diff --git a/src/components/GenerateCourse/AICourseLesson.tsx b/src/components/GenerateCourse/AICourseLesson.tsx index 3264c729d..3a10b5680 100644 --- a/src/components/GenerateCourse/AICourseLesson.tsx +++ b/src/components/GenerateCourse/AICourseLesson.tsx @@ -70,6 +70,9 @@ type AICourseLessonProps = { isAIChatsOpen: boolean; setIsAIChatsOpen: (isOpen: boolean) => void; + + isForkable: boolean; + onForkCourse: () => void; }; export function AICourseLesson(props: AICourseLessonProps) { @@ -91,6 +94,9 @@ export function AICourseLesson(props: AICourseLessonProps) { isAIChatsOpen, setIsAIChatsOpen, + + isForkable, + onForkCourse, } = props; const [isLoading, setIsLoading] = useState(true); @@ -108,8 +114,7 @@ export function AICourseLesson(props: AICourseLessonProps) { >([ { role: 'assistant', - content: - 'Hey, I am your AI instructor. How can I help you today? 🤖', + content: 'Hey, I am your AI instructor. How can I help you today? 🤖', isDefault: true, }, ]); @@ -205,7 +210,7 @@ export function AICourseLesson(props: AICourseLessonProps) { const questions = getQuestionsFromResult(result); setDefaultQuestions(questions); - + const newResult = result.replace( /=START_QUESTIONS=.*?=END_QUESTIONS=/, '', @@ -284,7 +289,7 @@ export function AICourseLesson(props: AICourseLessonProps) { <div className="relative mx-auto max-w-5xl"> <div className="bg-white p-8 pb-0 max-lg:px-4 max-lg:pt-3"> {(isGenerating || isLoading) && ( - <div className="absolute right-6 top-6 flex items-center justify-center"> + <div className="absolute top-6 right-6 flex items-center justify-center"> <Loader2Icon size={18} strokeWidth={3} @@ -299,7 +304,7 @@ export function AICourseLesson(props: AICourseLessonProps) { </div> {!isGenerating && !isLoading && ( - <div className="absolute top-2 right-2 lg:right-6 lg:top-6 flex items-center justify-between gap-2"> + <div className="absolute top-2 right-2 flex items-center justify-between gap-2 lg:top-6 lg:right-6"> <button onClick={() => setIsAIChatsOpen(!isAIChatsOpen)} className="rounded-full p-1 text-gray-400 hover:text-black max-lg:hidden" @@ -315,16 +320,25 @@ export function AICourseLesson(props: AICourseLessonProps) { onRegenerateLesson={(prompt) => { generateAiCourseContent(true, prompt); }} + isForkable={isForkable} + onForkCourse={onForkCourse} /> <button disabled={isLoading || isTogglingDone} className={cn( - 'flex items-center gap-1.5 rounded-full bg-black py-1 pl-2 pr-3 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs', + 'flex items-center gap-1.5 rounded-full bg-black py-1 pr-3 pl-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs', isLessonDone ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600', )} - onClick={() => toggleDone()} + onClick={() => { + if (isForkable) { + onForkCourse(); + return; + } + + toggleDone(); + }} > {isTogglingDone ? ( <> @@ -355,13 +369,13 @@ export function AICourseLesson(props: AICourseLessonProps) { )} </div> - <h1 className="mb-6 text-balance text-3xl font-semibold max-lg:mb-3 max-lg:text-xl"> + <h1 className="mb-6 text-3xl font-semibold text-balance max-lg:mb-3 max-lg:text-xl"> {currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} </h1> {!error && isLoggedIn() && ( <div - className="course-content prose prose-lg mt-8 max-w-full text-black prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800 max-lg:mt-4 max-lg:text-base max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm" + className="course-content prose prose-lg prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800 max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm mt-8 max-w-full text-black max-lg:mt-4 max-lg:text-base" dangerouslySetInnerHTML={{ __html: lessonHtml }} /> )} diff --git a/src/components/GenerateCourse/ModifyCoursePrompt.tsx b/src/components/GenerateCourse/ModifyCoursePrompt.tsx index 35583635d..94824f822 100644 --- a/src/components/GenerateCourse/ModifyCoursePrompt.tsx +++ b/src/components/GenerateCourse/ModifyCoursePrompt.tsx @@ -4,10 +4,17 @@ import { Modal } from '../Modal'; export type ModifyCoursePromptProps = { onClose: () => void; onSubmit: (prompt: string) => void; + title?: string; + description?: string; }; export function ModifyCoursePrompt(props: ModifyCoursePromptProps) { - const { onClose, onSubmit } = props; + const { + onClose, + onSubmit, + title = 'Give AI more context', + description = 'Pass additional information to the AI to generate a course outline.', + } = props; const [prompt, setPrompt] = useState(''); @@ -25,12 +32,8 @@ export function ModifyCoursePrompt(props: ModifyCoursePromptProps) { > <div className="flex flex-col gap-4"> <div> - <h2 className="mb-2 text-left text-xl font-semibold"> - Give AI more context - </h2> - <p className="text-sm text-gray-500"> - Pass additional information to the AI to generate a course outline. - </p> + <h2 className="mb-2 text-left text-xl font-semibold">{title}</h2> + <p className="text-sm text-gray-500">{description}</p> </div> <form className="flex flex-col gap-2" onSubmit={handleSubmit}> <textarea diff --git a/src/components/GenerateCourse/RegenerateLesson.tsx b/src/components/GenerateCourse/RegenerateLesson.tsx index 1323aa551..2427c119c 100644 --- a/src/components/GenerateCourse/RegenerateLesson.tsx +++ b/src/components/GenerateCourse/RegenerateLesson.tsx @@ -7,10 +7,12 @@ import { ModifyCoursePrompt } from './ModifyCoursePrompt'; type RegenerateLessonProps = { onRegenerateLesson: (prompt?: string) => void; + isForkable: boolean; + onForkCourse: () => void; }; export function RegenerateLesson(props: RegenerateLessonProps) { - const { onRegenerateLesson } = props; + const { onRegenerateLesson, isForkable, onForkCourse } = props; const [isDropdownVisible, setIsDropdownVisible] = useState(false); const [showUpgradeModal, setShowUpgradeModal] = useState(false); @@ -37,6 +39,11 @@ export function RegenerateLesson(props: RegenerateLessonProps) { onClose={() => setShowPromptModal(false)} onSubmit={(prompt) => { setShowPromptModal(false); + if (isForkable) { + onForkCourse(); + return; + } + onRegenerateLesson(prompt); }} /> @@ -52,9 +59,15 @@ export function RegenerateLesson(props: RegenerateLessonProps) { <PenSquare className="text-current" size={16} strokeWidth={2.5} /> </button> {isDropdownVisible && ( - <div className="absolute right-0 top-full min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white"> + <div className="absolute top-full right-0 min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white"> <button onClick={() => { + setIsDropdownVisible(false); + if (isForkable) { + onForkCourse(); + return; + } + onRegenerateLesson(); }} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100" @@ -69,6 +82,11 @@ export function RegenerateLesson(props: RegenerateLessonProps) { <button onClick={() => { setIsDropdownVisible(false); + if (isForkable) { + onForkCourse(); + return; + } + setShowPromptModal(true); }} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100" From f95ef58a93b3e6d9616a47d203fe15eadc601348 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Wed, 9 Apr 2025 16:31:55 +0100 Subject: [PATCH 09/31] Update AI tutor sidebar --- src/components/AITutor/AITutorSidebar.tsx | 14 +++++++-- src/components/ReactIcons/AITutorLogo.tsx | 36 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 src/components/ReactIcons/AITutorLogo.tsx diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index 0f4efaf2a..2f4e05972 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -1,4 +1,5 @@ -import { BookOpen, Bot, Compass, Plus, Star } from 'lucide-react'; +import { BookOpen, Compass, Plus, Star } from 'lucide-react'; +import { AITutorLogo } from '../ReactIcons/AITutorLogo'; type AITutorSidebarProps = { activeTab: AITutorTab; @@ -39,8 +40,15 @@ export function AITutorSidebar(props: AITutorSidebarProps) { return ( <aside className="hidden w-[255px] shrink-0 border-r border-slate-200 md:block"> <div className="flex flex-col items-start justify-center px-6 py-5"> - <Bot className="mb-2 size-8 text-black" /> - <h2 className="mb-0.5 text-base font-semibold text-black">AI Tutor</h2> + <div className="flex flex-row items-center gap-1"> + <AITutorLogo className="size-11 text-gray-500" color="black" /> + </div> + <div className="my-3 flex flex-col"> + <h2 className="-mb-px text-base font-semibold text-black"> + AI Tutor + </h2> + <span className="text-xs text-gray-500">by roadmap.sh</span> + </div> <p className="max-w-[150px] text-xs text-gray-500"> Your personalized learning companion for any topic </p> diff --git a/src/components/ReactIcons/AITutorLogo.tsx b/src/components/ReactIcons/AITutorLogo.tsx new file mode 100644 index 000000000..0ddac87dd --- /dev/null +++ b/src/components/ReactIcons/AITutorLogo.tsx @@ -0,0 +1,36 @@ +import type { SVGProps } from 'react'; + +type AITutorLogoProps = SVGProps<SVGSVGElement>; + +export function AITutorLogo(props: AITutorLogoProps) { + return ( + <svg + viewBox="0 0 310 248" + fill="none" + xmlns="http://www.w3.org/2000/svg" + {...props} + > + <rect width="310" height="247.211" fill="black" /> + <path + d="M205.179 45.1641H263.851V201.278H205.179V45.1641Z" + fill="white" + /> + <path + d="M45.1641 45.1743H104.598L104.598 202.048H45.1641L45.1641 45.1743Z" + fill="white" + /> + <path + d="M160.984 45.1743V103.716L45.1641 103.716L45.1641 45.1743H160.984Z" + fill="white" + /> + <path + d="M125.171 45.1743H184.605V201.284H125.171V45.1743Z" + fill="white" + /> + <path + d="M159.841 131.85V173.88L63.8324 173.88L63.8324 131.85H159.841Z" + fill="white" + /> + </svg> + ); +} From 0c6c6e0246c1cb73a5d8e6638bb388e45d7d84ef Mon Sep 17 00:00:00 2001 From: Arik Chakma <arikchangma@gmail.com> Date: Wed, 9 Apr 2025 21:34:14 +0600 Subject: [PATCH 10/31] wip --- src/components/GenerateCourse/AICourseContent.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index e2abc32dc..820b33197 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -8,6 +8,7 @@ import { Map, MessageCircleOffIcon, MessageCircleIcon, + GitForkIcon, } from 'lucide-react'; import { useEffect, useState } from 'react'; import { type AiCourse } from '../../lib/ai'; @@ -327,6 +328,17 @@ export function AICourseContent(props: AICourseContentProps) { onUpgrade={() => setShowUpgradeModal(true)} onShowLimits={() => setShowAILimitsPopup(true)} /> + {isForkable && ( + <button + className="hidden items-center justify-center gap-1 rounded-md bg-yellow-400 px-4 py-1 text-sm font-medium underline-offset-2 hover:bg-yellow-500 lg:flex" + onClick={() => { + setIsForkingCourse(true); + }} + > + <GitForkIcon className="size-4" /> + Fork + </button> + )} </div> </div> </header> From 204a421559762d1d1976e83749965e3c687caeff Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Wed, 9 Apr 2025 16:55:38 +0100 Subject: [PATCH 11/31] Add ai-course dropdown --- src/components/AITutor/AITutorLayout.tsx | 2 +- src/components/AITutor/DifficultyDropdown.tsx | 69 +++++++++++++++++++ src/components/GenerateCourse/AICourse.tsx | 65 +++++------------ .../GenerateCourse/FineTuneCourse.tsx | 2 +- 4 files changed, 88 insertions(+), 50 deletions(-) create mode 100644 src/components/AITutor/DifficultyDropdown.tsx diff --git a/src/components/AITutor/AITutorLayout.tsx b/src/components/AITutor/AITutorLayout.tsx index 5cfe7f3ce..c7663c48f 100644 --- a/src/components/AITutor/AITutorLayout.tsx +++ b/src/components/AITutor/AITutorLayout.tsx @@ -11,7 +11,7 @@ export function AITutorLayout(props: AITutorLayoutProps) { return ( <div className="flex flex-grow flex-row"> <AITutorSidebar activeTab={activeTab} /> - <div className="flex flex-grow flex-col bg-gray-100 px-4 py-4"> + <div className="flex flex-grow h-screen overflow-y-scroll flex-col bg-gray-100 px-4 py-4"> {children} </div> </div> diff --git a/src/components/AITutor/DifficultyDropdown.tsx b/src/components/AITutor/DifficultyDropdown.tsx new file mode 100644 index 000000000..0f5320090 --- /dev/null +++ b/src/components/AITutor/DifficultyDropdown.tsx @@ -0,0 +1,69 @@ +import { ChevronDown } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; +import { cn } from '../../lib/classname'; +import { + difficultyLevels, + type DifficultyLevel, +} from '../GenerateCourse/AICourse'; + +type DifficultyDropdownProps = { + value: DifficultyLevel; + onChange: (value: DifficultyLevel) => void; +}; + +export function DifficultyDropdown(props: DifficultyDropdownProps) { + const { value, onChange } = props; + + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( + <div className="relative" ref={dropdownRef}> + <button + type="button" + onClick={() => setIsOpen(!isOpen)} + className={cn( + 'flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200 hover:text-black', + )} + > + <span className="capitalize">{value}</span> + <ChevronDown size={16} className={cn(isOpen && 'rotate-180')} /> + </button> + + {isOpen && ( + <div className="absolute z-10 mt-1 flex flex-col overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"> + {difficultyLevels.map((level) => ( + <button + key={level} + type="button" + onClick={() => { + onChange(level); + setIsOpen(false); + }} + className={cn( + 'px-5 py-2 text-left text-sm capitalize hover:bg-gray-100', + value === level && 'bg-gray-200 font-medium hover:bg-gray-200', + )} + > + {level} + </button> + ))} + </div> + )} + </div> + ); +} diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index 428fb21fe..6fa957a42 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -1,9 +1,10 @@ -import { SearchIcon, WandIcon } from 'lucide-react'; +import { WandIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; import { cn } from '../../lib/classname'; import { isLoggedIn } from '../../lib/jwt'; import { showLoginPopup } from '../../lib/popup'; import { FineTuneCourse } from './FineTuneCourse'; +import { DifficultyDropdown } from '../AITutor/DifficultyDropdown'; import { clearFineTuneData, getCourseFineTuneData, @@ -71,7 +72,7 @@ export function AICourse(props: AICourseProps) { } return ( - <div className="flex w-full max-w-3xl mx-auto flex-grow flex-col justify-center"> + <div className="mx-auto flex w-full max-w-3xl flex-grow flex-col justify-center"> <h1 className="mb-2.5 text-center text-4xl font-bold max-sm:mb-2 max-sm:text-left max-sm:text-xl"> What can I help you learn? </h1> @@ -79,59 +80,27 @@ export function AICourse(props: AICourseProps) { Enter a topic below to generate a personalized course for it </p> - <div className="rounded-lg border border-gray-200 bg-white p-6 max-sm:p-4"> + <div className="rounded-lg border border-gray-300 bg-white"> <form - className="flex flex-col gap-5" + className="flex flex-col" onSubmit={(e) => { e.preventDefault(); onSubmit(); }} > - <div className="flex flex-col"> - <label - htmlFor="keyword" - className="mb-2.5 text-sm font-medium text-gray-700" - > - Course Topic - </label> - <div className="relative"> - <div className="absolute top-1/2 left-3 -translate-y-1/2 text-gray-400"> - <SearchIcon size={18} /> - </div> - <input - id="keyword" - type="text" - value={keyword} - onChange={(e) => 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:ring-1 focus:ring-gray-500 focus:outline-hidden max-sm:placeholder:text-base" - maxLength={50} - /> - </div> - </div> + <input + id="keyword" + type="text" + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask tutor to teach you..." + className="w-full rounded-md border-none bg-transparent px-4 pt-4 pb-8 text-gray-900 focus:outline-hidden max-sm:placeholder:text-base" + maxLength={50} + /> - <div className="flex flex-col"> - <label className="mb-2.5 text-sm font-medium text-gray-700"> - Difficulty Level - </label> - <div className="flex gap-2 max-sm:flex-col max-sm:gap-1"> - {difficultyLevels.map((level) => ( - <button - key={level} - type="button" - onClick={() => setDifficulty(level)} - className={cn( - 'rounded-md border px-4 py-2 capitalize max-sm:text-sm', - difficulty === level - ? 'border-gray-800 bg-gray-800 text-white' - : 'border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-200', - )} - > - {level} - </button> - ))} - </div> + <div className="flex flex-col px-4"> + <DifficultyDropdown value={difficulty} onChange={setDifficulty} /> </div> <FineTuneCourse diff --git a/src/components/GenerateCourse/FineTuneCourse.tsx b/src/components/GenerateCourse/FineTuneCourse.tsx index e1641ca59..0d6cef085 100644 --- a/src/components/GenerateCourse/FineTuneCourse.tsx +++ b/src/components/GenerateCourse/FineTuneCourse.tsx @@ -52,7 +52,7 @@ export function FineTuneCourse(props: FineTuneCourseProps) { } = props; return ( - <div className="flex flex-col overflow-hidden rounded-lg border border-gray-200 transition-all"> + <div className="flex flex-col overflow-hidden transition-all"> <label className={cn( 'group flex cursor-pointer select-none flex-row items-center gap-2.5 px-4 py-3 text-left text-gray-500 transition-colors hover:bg-gray-100 focus:outline-hidden', From e660d9da15b64c26130f0f4a008735a28799a98f Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Wed, 9 Apr 2025 17:07:21 +0100 Subject: [PATCH 12/31] Update --- src/components/AITutor/AITutorSidebar.tsx | 4 +++- src/components/GenerateCourse/AICourse.tsx | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index 2f4e05972..c4166a676 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -47,7 +47,9 @@ export function AITutorSidebar(props: AITutorSidebarProps) { <h2 className="-mb-px text-base font-semibold text-black"> AI Tutor </h2> - <span className="text-xs text-gray-500">by roadmap.sh</span> + <span className="text-xs text-gray-500"> + by <a href="/" className="hover:underline underline-offset-2">roadmap.sh</a> + </span> </div> <p className="max-w-[150px] text-xs text-gray-500"> Your personalized learning companion for any topic diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index 6fa957a42..b002f641f 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -1,4 +1,4 @@ -import { WandIcon } from 'lucide-react'; +import { Settings2Icon, WandIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; import { cn } from '../../lib/classname'; import { isLoggedIn } from '../../lib/jwt'; @@ -99,8 +99,18 @@ export function AICourse(props: AICourseProps) { maxLength={50} /> - <div className="flex flex-col px-4"> - <DifficultyDropdown value={difficulty} onChange={setDifficulty} /> + <div className="flex flex-row gap-2 px-4"> + <div className="flex flex-row gap-2"> + <DifficultyDropdown value={difficulty} onChange={setDifficulty} /> + </div> + <button + type="button" + onClick={() => setHasFineTuneData(!hasFineTuneData)} + className="flex px-2 py-1 bg-gray-100 rounded-full flex-row items-center gap-1 text-sm text-gray-500 hover:text-gray-700" + > + <Settings2Icon size={16} /> + Settings + </button> </div> <FineTuneCourse From 0f29273ff334efacdc06181906479e3d06714430 Mon Sep 17 00:00:00 2001 From: Arik Chakma <arikchangma@gmail.com> Date: Wed, 9 Apr 2025 22:15:22 +0600 Subject: [PATCH 13/31] fix: ai chat window position --- .../GenerateCourse/AICourseLessonChat.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/GenerateCourse/AICourseLessonChat.tsx b/src/components/GenerateCourse/AICourseLessonChat.tsx index b0da9256d..4e6fc081e 100644 --- a/src/components/GenerateCourse/AICourseLessonChat.tsx +++ b/src/components/GenerateCourse/AICourseLessonChat.tsx @@ -221,23 +221,26 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) { minSize={20} id="course-chat-content" order={2} - className="relative h-full max-lg:fixed max-lg:inset-0 max-lg:data-[chat-state=open]:flex max-lg:data-[chat-state=closed]:hidden" + className="relative h-full max-lg:fixed! max-lg:inset-0! max-lg:data-[chat-state=closed]:hidden max-lg:data-[chat-state=open]:flex" data-chat-state={isAIChatsOpen ? 'open' : 'closed'} > <div - className="absolute inset-y-0 right-0 z-20 flex w-full flex-col overflow-hidden bg-white data-[state=open]:flex data-[state=closed]:hidden" + className="absolute inset-y-0 right-0 z-20 flex w-full flex-col overflow-hidden bg-white data-[state=closed]:hidden data-[state=open]:flex" data-state={isAIChatsOpen ? 'open' : 'closed'} > <button onClick={onClose} - className="absolute right-2 top-2 z-20 hidden rounded-full p-1 text-gray-400 hover:text-black max-lg:block" + className="absolute top-2 right-2 z-20 hidden rounded-full p-1 text-gray-400 hover:text-black max-lg:block" > <XIcon className="size-4 stroke-[2.5]" /> </button> <div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm"> <h4 className="flex items-center gap-2 text-base font-medium"> - <Bot className="size-5 shrink-0 text-black relative -top-[1px]" strokeWidth={2.5} /> + <Bot + className="relative -top-[1px] size-5 shrink-0 text-black" + strokeWidth={2.5} + /> AI Instructor </h4> <button @@ -278,7 +281,7 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) { /> {chat.isDefault && defaultQuestions?.length > 1 && ( - <div className="mb-1 mt-0.5"> + <div className="mt-0.5 mb-1"> <p className="mb-2 text-xs font-normal text-gray-500"> Some questions you might have about this lesson. </p> @@ -442,7 +445,7 @@ function CapabilityCard({ > <div className="flex items-center gap-2"> {icon} - <span className="text-[13px] font-medium leading-none text-black"> + <span className="text-[13px] leading-none font-medium text-black"> {title} </span> </div> From 150d38af2b69b3e121642715657586d74adc6952 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Wed, 9 Apr 2025 17:34:27 +0100 Subject: [PATCH 14/31] Course explanation changes --- src/components/GenerateCourse/AICourse.tsx | 60 +++++++++------- .../GenerateCourse/FineTuneCourse.tsx | 71 +++++++------------ 2 files changed, 59 insertions(+), 72 deletions(-) diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index b002f641f..5f01d6733 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -1,6 +1,5 @@ -import { Settings2Icon, WandIcon } from 'lucide-react'; +import { WandIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { cn } from '../../lib/classname'; import { isLoggedIn } from '../../lib/jwt'; import { showLoginPopup } from '../../lib/popup'; import { FineTuneCourse } from './FineTuneCourse'; @@ -11,6 +10,7 @@ import { getLastSessionId, storeFineTuneData, } from '../../lib/ai'; +import { cn } from '../../lib/classname'; export const difficultyLevels = [ 'beginner', @@ -94,22 +94,46 @@ export function AICourse(props: AICourseProps) { value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Ask tutor to teach you..." + placeholder="e.g. JavaScript Promises, React Hooks, Go Routines etc" className="w-full rounded-md border-none bg-transparent px-4 pt-4 pb-8 text-gray-900 focus:outline-hidden max-sm:placeholder:text-base" maxLength={50} /> - <div className="flex flex-row gap-2 px-4"> - <div className="flex flex-row gap-2"> - <DifficultyDropdown value={difficulty} onChange={setDifficulty} /> + <div className="flex flex-row items-center justify-between gap-2 px-4 pb-4"> + <div className="flex flex-row items-center gap-2"> + <div className="flex flex-row gap-2"> + <DifficultyDropdown + value={difficulty} + onChange={setDifficulty} + /> + </div> + <label + htmlFor="fine-tune-checkbox" + className="flex cursor-pointer flex-row items-center gap-1 rounded-full bg-gray-100 px-4 py-1 text-sm text-gray-700 hover:bg-gray-200 hover:text-gray-700" + > + <input + type="checkbox" + checked={hasFineTuneData} + onChange={() => setHasFineTuneData(!hasFineTuneData)} + className="mr-1" + id="fine-tune-checkbox" + /> + Explain the course + </label> </div> + <button - type="button" - onClick={() => setHasFineTuneData(!hasFineTuneData)} - className="flex px-2 py-1 bg-gray-100 rounded-full flex-row items-center gap-1 text-sm text-gray-500 hover:text-gray-700" + type="submit" + disabled={!keyword.trim()} + className={cn( + 'flex items-center justify-center rounded-full px-4 py-1 text-white transition-colors text-sm', + !keyword.trim() + ? 'cursor-not-allowed bg-gray-400' + : 'bg-black hover:bg-gray-800', + )} > - <Settings2Icon size={16} /> - Settings + <WandIcon size={18} className="mr-2" /> + Generate Course </button> </div> @@ -123,20 +147,6 @@ export function AICourse(props: AICourseProps) { setGoal={setGoal} setCustomInstructions={setCustomInstructions} /> - - <button - type="submit" - disabled={!keyword.trim()} - className={cn( - 'mt-2 flex items-center justify-center rounded-md px-4 py-2 font-medium text-white transition-colors max-sm:text-sm', - !keyword.trim() - ? 'cursor-not-allowed bg-gray-400' - : 'bg-black hover:bg-gray-800', - )} - > - <WandIcon size={18} className="mr-2" /> - Generate Course - </button> </form> </div> </div> diff --git a/src/components/GenerateCourse/FineTuneCourse.tsx b/src/components/GenerateCourse/FineTuneCourse.tsx index 0d6cef085..e727c8dfb 100644 --- a/src/components/GenerateCourse/FineTuneCourse.tsx +++ b/src/components/GenerateCourse/FineTuneCourse.tsx @@ -1,5 +1,3 @@ -import { cn } from '../../lib/classname'; - type QuestionProps = { label: string; placeholder: string; @@ -51,52 +49,31 @@ export function FineTuneCourse(props: FineTuneCourseProps) { setHasFineTuneData, } = props; - return ( - <div className="flex flex-col overflow-hidden transition-all"> - <label - className={cn( - 'group flex cursor-pointer select-none flex-row items-center gap-2.5 px-4 py-3 text-left text-gray-500 transition-colors hover:bg-gray-100 focus:outline-hidden', - hasFineTuneData && 'bg-gray-100', - )} - > - <input - id="fine-tune-checkbox" - type="checkbox" - className="h-4 w-4 group-hover:fill-current" - checked={hasFineTuneData} - onChange={() => { - setHasFineTuneData(!hasFineTuneData); - }} - /> - Tell us more to tailor the course (optional){' '} - <span className="ml-auto rounded-md bg-gray-400 px-2 py-0.5 text-xs text-white hidden sm:block"> - recommended - </span> - </label> + if (!hasFineTuneData) { + return null; + } - {hasFineTuneData && ( - <div className="mt-0 flex flex-col"> - <Question - label="Tell us about your self" - placeholder="e.g. I am a frontend developer and have good knowledge of HTML, CSS, and JavaScript." - value={about} - onChange={setAbout} - autoFocus={true} - /> - <Question - label="What is your goal with this course?" - placeholder="e.g. I want to be able to build Node.js APIs with Express.js and MongoDB." - value={goal} - onChange={setGoal} - /> - <Question - label="Custom Instructions (Optional)" - placeholder="Give additional instructions to the AI as if you were giving them to a friend." - value={customInstructions} - onChange={setCustomInstructions} - /> - </div> - )} + return ( + <div className="mt-0 flex flex-col"> + <Question + label="Tell us about your self" + placeholder="e.g. I am a frontend developer and have good knowledge of HTML, CSS, and JavaScript." + value={about} + onChange={setAbout} + autoFocus={true} + /> + <Question + label="What is your goal with this course?" + placeholder="e.g. I want to be able to build Node.js APIs with Express.js and MongoDB." + value={goal} + onChange={setGoal} + /> + <Question + label="Custom Instructions (Optional)" + placeholder="Give additional instructions to the AI as if you were giving them to a friend." + value={customInstructions} + onChange={setCustomInstructions} + /> </div> ); } From b3ff46ea712f8ed5bffabe1a1eebc8e185fcf235 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Wed, 9 Apr 2025 19:11:48 +0100 Subject: [PATCH 15/31] Update course --- src/components/GenerateCourse/AICourse.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index 5f01d6733..d3d860f9c 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -73,7 +73,7 @@ export function AICourse(props: AICourseProps) { return ( <div className="mx-auto flex w-full max-w-3xl flex-grow flex-col justify-center"> - <h1 className="mb-2.5 text-center text-4xl font-bold max-sm:mb-2 max-sm:text-left max-sm:text-xl"> + <h1 className="mb-2.5 text-center text-4xl font-semibold max-sm:mb-2 max-sm:text-left max-sm:text-xl"> What can I help you learn? </h1> <p className="mb-6 text-center text-lg text-gray-600 max-sm:hidden max-sm:text-left max-sm:text-sm"> @@ -118,7 +118,7 @@ export function AICourse(props: AICourseProps) { className="mr-1" id="fine-tune-checkbox" /> - Explain the course + Explain more for better course </label> </div> From 61f5a81d20d17130600e1088ae3c32fb521febd1 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Thu, 10 Apr 2025 15:19:50 +0100 Subject: [PATCH 16/31] Tutor sidebar changes --- src/components/AITutor/AIFeaturedCoursesListing.tsx | 6 ++---- src/components/AITutor/AITutorSidebar.tsx | 13 ++++++++----- src/components/GenerateCourse/AICourse.tsx | 2 +- src/pages/ai/{explore.astro => community.astro} | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) rename src/pages/ai/{explore.astro => community.astro} (93%) diff --git a/src/components/AITutor/AIFeaturedCoursesListing.tsx b/src/components/AITutor/AIFeaturedCoursesListing.tsx index a5df649ed..72520266f 100644 --- a/src/components/AITutor/AIFeaturedCoursesListing.tsx +++ b/src/components/AITutor/AIFeaturedCoursesListing.tsx @@ -1,8 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { - listFeaturedAiCoursesOptions, - listUserAiCoursesOptions, - type ListUserAiCoursesQuery, + listFeaturedAiCoursesOptions, type ListUserAiCoursesQuery } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; import { useEffect, useState } from 'react'; @@ -53,7 +51,7 @@ export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { <> <div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1"> <div className="flex items-center gap-2"> - <h2 className="text-lg font-semibold">Stuff Picks</h2> + <h2 className="text-lg font-semibold">Staff Picks</h2> </div> </div> diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index c4166a676..c6b1a7aa5 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -1,4 +1,4 @@ -import { BookOpen, Compass, Plus, Star } from 'lucide-react'; +import { BookOpen, Compass, Plus, Star, Users2 } from 'lucide-react'; import { AITutorLogo } from '../ReactIcons/AITutorLogo'; type AITutorSidebarProps = { @@ -25,9 +25,9 @@ const sidebarItems = [ icon: Star, }, { - key: 'explore', - label: 'Explore', - href: '/ai/explore', + key: 'community', + label: 'Community', + href: '/ai/community', icon: Compass, }, ]; @@ -48,7 +48,10 @@ export function AITutorSidebar(props: AITutorSidebarProps) { AI Tutor </h2> <span className="text-xs text-gray-500"> - by <a href="/" className="hover:underline underline-offset-2">roadmap.sh</a> + by{' '} + <a href="/" className="underline-offset-2 hover:underline"> + roadmap.sh + </a> </span> </div> <p className="max-w-[150px] text-xs text-gray-500"> diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index d3d860f9c..d100a7d97 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -118,7 +118,7 @@ export function AICourse(props: AICourseProps) { className="mr-1" id="fine-tune-checkbox" /> - Explain more for better course + Explain more for a better course </label> </div> diff --git a/src/pages/ai/explore.astro b/src/pages/ai/community.astro similarity index 93% rename from src/pages/ai/explore.astro rename to src/pages/ai/community.astro index b743e98af..008a46e1d 100644 --- a/src/pages/ai/explore.astro +++ b/src/pages/ai/community.astro @@ -11,7 +11,7 @@ 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.' > - <AITutorLayout activeTab='explore' client:load> + <AITutorLayout activeTab='community' client:load> <section class='flex grow flex-col bg-gray-100'> <div class='mx-auto w-full flex max-w-4xl flex-col py-10 max-sm:py-4'> <AIExploreCourseListing client:load /> From 4fbea4680c14f1955d4ee68809af52962fd10020 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Thu, 10 Apr 2025 15:34:32 +0100 Subject: [PATCH 17/31] Refactor staff picks and community --- .../AITutor/AIFeaturedCoursesListing.tsx | 66 ++++++++----- src/components/AITutor/AITutorHeader.tsx | 46 +++++++++ src/components/AITutor/AITutorLimits.tsx | 45 +++++++++ .../GenerateCourse/UserCoursesList.tsx | 96 ++++++------------- src/pages/ai/staff-picks.astro | 6 +- 5 files changed, 160 insertions(+), 99 deletions(-) create mode 100644 src/components/AITutor/AITutorHeader.tsx create mode 100644 src/components/AITutor/AITutorLimits.tsx diff --git a/src/components/AITutor/AIFeaturedCoursesListing.tsx b/src/components/AITutor/AIFeaturedCoursesListing.tsx index 72520266f..8799ce06d 100644 --- a/src/components/AITutor/AIFeaturedCoursesListing.tsx +++ b/src/components/AITutor/AIFeaturedCoursesListing.tsx @@ -4,10 +4,13 @@ import { } 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'; import { Pagination } from '../Pagination/Pagination'; +import { AILoadingState } from './AILoadingState'; +import { AITutorTallMessage } from './AITutorTallMessage'; +import { BookOpen } from 'lucide-react'; +import { AITutorHeader } from './AITutorHeader'; type AIFeaturedCoursesListingProps = {}; @@ -47,34 +50,45 @@ export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { } }, [pageState]); - return ( - <> - <div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1"> - <div className="flex items-center gap-2"> - <h2 className="text-lg font-semibold">Staff Picks</h2> - </div> - </div> + if (isInitialLoading || isFeaturedAiCoursesLoading) { + return ( + <AILoadingState + title="Loading featured courses" + subtitle="This may take a moment..." + /> + ); + } - {(isFeaturedAiCoursesLoading || isInitialLoading) && ( - <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> - <Loader2 - className="size-4 animate-spin text-gray-400" - strokeWidth={2.5} - /> - <p className="text-sm font-medium text-gray-600">Loading...</p> - </div> - )} + if (courses.length === 0) { + return ( + <AITutorTallMessage + title="No featured courses" + subtitle="There are no featured courses available at the moment." + icon={BookOpen} + buttonText="Browse all courses" + onButtonClick={() => { + window.location.href = '/ai'; + }} + /> + ); + } + + return ( + <div className="w-full"> + <AITutorHeader title="Featured Courses" /> {!isFeaturedAiCoursesLoading && courses && courses.length > 0 && ( <div className="flex flex-col gap-2"> - {courses.map((course) => ( - <AICourseCard - key={course._id} - course={course} - showActions={false} - showProgress={false} - /> - ))} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2"> + {courses.map((course) => ( + <AICourseCard + key={course._id} + course={course} + showActions={false} + showProgress={false} + /> + ))} + </div> <Pagination totalCount={featuredAiCourses?.totalCount || 0} @@ -98,6 +112,6 @@ export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { </p> </div> )} - </> + </div> ); } diff --git a/src/components/AITutor/AITutorHeader.tsx b/src/components/AITutor/AITutorHeader.tsx new file mode 100644 index 000000000..07b447422 --- /dev/null +++ b/src/components/AITutor/AITutorHeader.tsx @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query'; +import { AITutorLimits } from './AITutorLimits'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { queryClient } from '../../stores/query-client'; + +type AITutorHeaderProps = { + title: string; + isPaidUser: boolean; + isPaidUserLoading: boolean; + setShowUpgradePopup: (show: boolean) => void; + children?: React.ReactNode; +}; + +export function AITutorHeader(props: AITutorHeaderProps) { + const { + title, + isPaidUser, + isPaidUserLoading, + setShowUpgradePopup, + children, + } = props; + + const { data: limits } = useQuery(getAiCourseLimitOptions(), queryClient); + + const { used, limit } = limits ?? { used: 0, limit: 0 }; + + return ( + <div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1"> + <div className="flex items-center gap-2"> + <h2 className="text-lg font-semibold">{title}</h2> + </div> + + <div className="flex items-center gap-2"> + <AITutorLimits + used={used} + limit={limit} + isPaidUser={isPaidUser} + isPaidUserLoading={isPaidUserLoading} + onUpgradeClick={() => setShowUpgradePopup(true)} + /> + + {children} + </div> + </div> + ); +} diff --git a/src/components/AITutor/AITutorLimits.tsx b/src/components/AITutor/AITutorLimits.tsx new file mode 100644 index 000000000..3cc93730e --- /dev/null +++ b/src/components/AITutor/AITutorLimits.tsx @@ -0,0 +1,45 @@ +import { Gift } from 'lucide-react'; +import { cn } from '../../lib/classname'; + +type AITutorLimitsProps = { + used: number; + limit: number; + isPaidUser: boolean; + isPaidUserLoading: boolean; + onUpgradeClick: () => void; +}; + +export function AITutorLimits(props: AITutorLimitsProps) { + const limitUsedPercentage = Math.round((props.used / props.limit) * 100); + + if (props.used <= 0 || props.limit <= 0 || props.isPaidUserLoading) { + return null; + } + + return ( + <div + className={cn( + 'pointer-events-none flex items-center gap-2 opacity-0 transition-opacity', + { + 'pointer-events-auto opacity-100': !props.isPaidUser, + }, + )} + > + <p className="flex items-center text-sm text-yellow-600"> + <span className="max-md:hidden"> + {limitUsedPercentage}% of daily limit used{' '} + </span> + <span className="inline md:hidden"> + {limitUsedPercentage}% used + </span> + <button + onClick={props.onUpgradeClick} + className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pr-2 pl-1.5 text-xs text-white" + > + <Gift className="size-4" /> + Upgrade + </button> + </p> + </div> + ); +} \ No newline at end of file diff --git a/src/components/GenerateCourse/UserCoursesList.tsx b/src/components/GenerateCourse/UserCoursesList.tsx index ee56d665a..b39e061e9 100644 --- a/src/components/GenerateCourse/UserCoursesList.tsx +++ b/src/components/GenerateCourse/UserCoursesList.tsx @@ -1,23 +1,22 @@ import { useQuery } from '@tanstack/react-query'; +import { BookOpen } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; import { - getAiCourseLimitOptions, listUserAiCoursesOptions, type ListUserAiCoursesQuery, } from '../../queries/ai-course'; -import { queryClient } from '../../stores/query-client'; -import { AICourseCard } from './AICourseCard'; -import { useEffect, useState } from 'react'; -import { BookOpen, Gift } from 'lucide-react'; -import { isLoggedIn } from '../../lib/jwt'; -import { showLoginPopup } from '../../lib/popup'; -import { cn } from '../../lib/classname'; import { useIsPaidUser } from '../../queries/billing'; -import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; -import { getUrlParams, setUrlParams, deleteUrlParam } from '../../lib/browser'; -import { AICourseSearch } from './AICourseSearch'; -import { Pagination } from '../Pagination/Pagination'; +import { queryClient } from '../../stores/query-client'; import { AILoadingState } from '../AITutor/AILoadingState'; +import { AITutorHeader } from '../AITutor/AITutorHeader'; import { AITutorTallMessage } from '../AITutor/AITutorTallMessage'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { Pagination } from '../Pagination/Pagination'; +import { AICourseCard } from './AICourseCard'; +import { AICourseSearch } from './AICourseSearch'; type UserCoursesListProps = {}; @@ -31,12 +30,6 @@ export function UserCoursesList(props: UserCoursesListProps) { query: '', }); - const { data: limits, isLoading: isLimitsLoading } = useQuery( - getAiCourseLimitOptions(), - queryClient, - ); - - const { used, limit } = limits ?? { used: 0, limit: 0 }; const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser(); const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery( @@ -49,8 +42,6 @@ export function UserCoursesList(props: UserCoursesListProps) { }, [userAiCourses]); const courses = userAiCourses?.data ?? []; - const isAuthenticated = isLoggedIn(); - const limitUsedPercentage = Math.round((used / limit) * 100); useEffect(() => { const queryParams = getUrlParams(); @@ -116,55 +107,24 @@ export function UserCoursesList(props: UserCoursesListProps) { {showUpgradePopup && ( <UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} /> )} - <div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1"> - <div className="flex items-center gap-2"> - <h2 className="text-lg font-semibold"> - <span className="max-md:hidden">Your </span>Courses - </h2> - </div> - <div className="flex items-center gap-2"> - {used > 0 && limit > 0 && !isPaidUserLoading && ( - <div - className={cn( - 'pointer-events-none flex items-center gap-2 opacity-0 transition-opacity', - { - 'pointer-events-auto opacity-100': !isPaidUser, - }, - )} - > - <p className="flex items-center text-sm text-yellow-600"> - <span className="max-md:hidden"> - {limitUsedPercentage}% of daily limit used{' '} - </span> - <span className="inline md:hidden"> - {limitUsedPercentage}% used - </span> - <button - onClick={() => { - setShowUpgradePopup(true); - }} - className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pr-2 pl-1.5 text-xs text-white" - > - <Gift className="size-4" /> - Upgrade - </button> - </p> - </div> - )} - - <AICourseSearch - value={pageState?.query || ''} - onChange={(value) => { - setPageState({ - ...pageState, - query: value, - currPage: '1', - }); - }} - /> - </div> - </div> + <AITutorHeader + title="Your Courses" + isPaidUser={isPaidUser} + isPaidUserLoading={isPaidUserLoading} + setShowUpgradePopup={setShowUpgradePopup} + > + <AICourseSearch + value={pageState?.query || ''} + onChange={(value) => { + setPageState({ + ...pageState, + query: value, + currPage: '1', + }); + }} + /> + </AITutorHeader> {!isUserAiCoursesLoading && courses && courses.length > 0 && ( <div className="flex flex-col gap-2"> diff --git a/src/pages/ai/staff-picks.astro b/src/pages/ai/staff-picks.astro index 840680333..8fcae506a 100644 --- a/src/pages/ai/staff-picks.astro +++ b/src/pages/ai/staff-picks.astro @@ -12,10 +12,6 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.' > <AITutorLayout activeTab='staff-picks' client:load> - <section class='flex grow flex-col bg-gray-100'> - <div class='container mx-auto flex max-w-3xl flex-col py-10 max-sm:py-4'> - <AIFeaturedCoursesListing client:load /> - </div> - </section> + <AIFeaturedCoursesListing client:load /> </AITutorLayout> </SkeletonLayout> From 3918112884e59a5729466a7b1e41127116d00f57 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Thu, 10 Apr 2025 16:39:01 +0100 Subject: [PATCH 18/31] Update UI for a course --- .../AITutor/AIExploreCourseListing.tsx | 4 +--- .../AITutor/AIFeaturedCoursesListing.tsx | 20 +++++++++++++------ src/components/AITutor/AITutorHeader.tsx | 16 +++++---------- .../GenerateCourse/UserCoursesList.tsx | 11 ++-------- src/pages/ai/community.astro | 6 +----- 5 files changed, 23 insertions(+), 34 deletions(-) diff --git a/src/components/AITutor/AIExploreCourseListing.tsx b/src/components/AITutor/AIExploreCourseListing.tsx index 4f132e871..93ea36f46 100644 --- a/src/components/AITutor/AIExploreCourseListing.tsx +++ b/src/components/AITutor/AIExploreCourseListing.tsx @@ -3,9 +3,7 @@ import { useEffect, useState } from 'react'; import { AlertCircle, Loader2 } from 'lucide-react'; import { AICourseCard } from '../GenerateCourse/AICourseCard'; -type AIExploreCourseListingProps = {}; - -export function AIExploreCourseListing(props: AIExploreCourseListingProps) { +export function AIExploreCourseListing() { const [isInitialLoading, setIsInitialLoading] = useState(true); const { diff --git a/src/components/AITutor/AIFeaturedCoursesListing.tsx b/src/components/AITutor/AIFeaturedCoursesListing.tsx index 8799ce06d..2b7fa3468 100644 --- a/src/components/AITutor/AIFeaturedCoursesListing.tsx +++ b/src/components/AITutor/AIFeaturedCoursesListing.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { - listFeaturedAiCoursesOptions, type ListUserAiCoursesQuery + listFeaturedAiCoursesOptions, + type ListUserAiCoursesQuery, } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; import { useEffect, useState } from 'react'; @@ -11,11 +12,11 @@ import { AILoadingState } from './AILoadingState'; import { AITutorTallMessage } from './AITutorTallMessage'; import { BookOpen } from 'lucide-react'; import { AITutorHeader } from './AITutorHeader'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; -type AIFeaturedCoursesListingProps = {}; - -export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { +export function AIFeaturedCoursesListing() { const [isInitialLoading, setIsInitialLoading] = useState(true); + const [showUpgradePopup, setShowUpgradePopup] = useState(false); const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({ perPage: '20', @@ -75,11 +76,18 @@ export function AIFeaturedCoursesListing(props: AIFeaturedCoursesListingProps) { return ( <div className="w-full"> - <AITutorHeader title="Featured Courses" /> + {showUpgradePopup && ( + <UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} /> + )} + + <AITutorHeader + title="Featured Courses" + onUpgradeClick={() => setShowUpgradePopup(true)} + /> {!isFeaturedAiCoursesLoading && courses && courses.length > 0 && ( <div className="flex flex-col gap-2"> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2"> + <div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3"> {courses.map((course) => ( <AICourseCard key={course._id} diff --git a/src/components/AITutor/AITutorHeader.tsx b/src/components/AITutor/AITutorHeader.tsx index 07b447422..77d7c881b 100644 --- a/src/components/AITutor/AITutorHeader.tsx +++ b/src/components/AITutor/AITutorHeader.tsx @@ -2,25 +2,19 @@ import { useQuery } from '@tanstack/react-query'; import { AITutorLimits } from './AITutorLimits'; import { getAiCourseLimitOptions } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; +import { useIsPaidUser } from '../../queries/billing'; type AITutorHeaderProps = { title: string; - isPaidUser: boolean; - isPaidUserLoading: boolean; - setShowUpgradePopup: (show: boolean) => void; + onUpgradeClick: () => void; children?: React.ReactNode; }; export function AITutorHeader(props: AITutorHeaderProps) { - const { - title, - isPaidUser, - isPaidUserLoading, - setShowUpgradePopup, - children, - } = props; + const { title, onUpgradeClick, children } = props; const { data: limits } = useQuery(getAiCourseLimitOptions(), queryClient); + const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser(); const { used, limit } = limits ?? { used: 0, limit: 0 }; @@ -36,7 +30,7 @@ export function AITutorHeader(props: AITutorHeaderProps) { limit={limit} isPaidUser={isPaidUser} isPaidUserLoading={isPaidUserLoading} - onUpgradeClick={() => setShowUpgradePopup(true)} + onUpgradeClick={onUpgradeClick} /> {children} diff --git a/src/components/GenerateCourse/UserCoursesList.tsx b/src/components/GenerateCourse/UserCoursesList.tsx index b39e061e9..943bead6f 100644 --- a/src/components/GenerateCourse/UserCoursesList.tsx +++ b/src/components/GenerateCourse/UserCoursesList.tsx @@ -8,7 +8,6 @@ import { listUserAiCoursesOptions, type ListUserAiCoursesQuery, } from '../../queries/ai-course'; -import { useIsPaidUser } from '../../queries/billing'; import { queryClient } from '../../stores/query-client'; import { AILoadingState } from '../AITutor/AILoadingState'; import { AITutorHeader } from '../AITutor/AITutorHeader'; @@ -18,9 +17,7 @@ import { Pagination } from '../Pagination/Pagination'; import { AICourseCard } from './AICourseCard'; import { AICourseSearch } from './AICourseSearch'; -type UserCoursesListProps = {}; - -export function UserCoursesList(props: UserCoursesListProps) { +export function UserCoursesList() { const [isInitialLoading, setIsInitialLoading] = useState(true); const [showUpgradePopup, setShowUpgradePopup] = useState(false); @@ -30,8 +27,6 @@ export function UserCoursesList(props: UserCoursesListProps) { query: '', }); - const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser(); - const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery( listUserAiCoursesOptions(pageState), queryClient, @@ -110,9 +105,7 @@ export function UserCoursesList(props: UserCoursesListProps) { <AITutorHeader title="Your Courses" - isPaidUser={isPaidUser} - isPaidUserLoading={isPaidUserLoading} - setShowUpgradePopup={setShowUpgradePopup} + onUpgradeClick={() => setShowUpgradePopup(true)} > <AICourseSearch value={pageState?.query || ''} diff --git a/src/pages/ai/community.astro b/src/pages/ai/community.astro index 008a46e1d..0f9422936 100644 --- a/src/pages/ai/community.astro +++ b/src/pages/ai/community.astro @@ -12,10 +12,6 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.' > <AITutorLayout activeTab='community' client:load> - <section class='flex grow flex-col bg-gray-100'> - <div class='mx-auto w-full flex max-w-4xl flex-col py-10 max-sm:py-4'> - <AIExploreCourseListing client:load /> - </div> - </section> + <AIExploreCourseListing client:load /> </AITutorLayout> </SkeletonLayout> From ab981b8c88e4ed9ef1da3da7845fd3965f79c9e6 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Thu, 10 Apr 2025 19:45:03 +0100 Subject: [PATCH 19/31] Improve pagination --- .../AITutor/AIExploreCourseListing.tsx | 81 ++++++++++--------- .../GenerateCourse/UserCoursesList.tsx | 2 +- src/queries/ai-course.ts | 2 +- 3 files changed, 45 insertions(+), 40 deletions(-) diff --git a/src/components/AITutor/AIExploreCourseListing.tsx b/src/components/AITutor/AIExploreCourseListing.tsx index 93ea36f46..c8b25f560 100644 --- a/src/components/AITutor/AIExploreCourseListing.tsx +++ b/src/components/AITutor/AIExploreCourseListing.tsx @@ -2,9 +2,13 @@ import { useListExploreAiCourses } from '../../queries/ai-course'; import { useEffect, useState } from 'react'; import { AlertCircle, Loader2 } from 'lucide-react'; import { AICourseCard } from '../GenerateCourse/AICourseCard'; +import { AILoadingState } from './AILoadingState'; +import { AITutorHeader } from './AITutorHeader'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; export function AIExploreCourseListing() { const [isInitialLoading, setIsInitialLoading] = useState(true); + const [showUpgradePopup, setShowUpgradePopup] = useState(false); const { data, @@ -23,50 +27,51 @@ export function AIExploreCourseListing() { const courses = data?.pages.flatMap((page) => page.data) ?? []; - return ( - <> - <div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1"> - <div className="flex items-center gap-2"> - <h2 className="text-lg font-semibold">Explore Courses</h2> - </div> + if (isInitialLoading || isExploreAiCoursesLoading) { + return ( + <AILoadingState + title="Loading courses" + subtitle="This may take a moment..." + /> + ); + } + + if (error) { + return ( + <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> + <AlertCircle className="size-4 text-red-500" /> + <p className="text-sm font-medium text-red-600"> + {error?.message ?? 'Error loading courses.'} + </p> </div> + ); + } - {(isExploreAiCoursesLoading || isInitialLoading) && ( - <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> - <Loader2 - className="size-4 animate-spin text-gray-400" - strokeWidth={2.5} - /> - <p className="text-sm font-medium text-gray-600">Loading...</p> - </div> + return ( + <> + {showUpgradePopup && ( + <UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} /> )} - {error && !isExploreAiCoursesLoading && !isInitialLoading && ( - <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> - <AlertCircle className="size-4 text-red-500" /> - <p className="text-sm font-medium text-red-600"> - {error?.message ?? 'Error loading courses.'} - </p> + <AITutorHeader + title="Explore Courses" + onUpgradeClick={() => setShowUpgradePopup(true)} + /> + + {courses && courses.length > 0 && ( + <div className="grid grid-cols-3 gap-2"> + {courses.map((course) => ( + <AICourseCard + key={course._id} + course={course} + showActions={false} + showProgress={false} + /> + ))} </div> )} - {!isExploreAiCoursesLoading && - courses && - courses.length > 0 && - !error && ( - <div className="grid grid-cols-2 gap-2"> - {courses.map((course) => ( - <AICourseCard - key={course._id} - course={course} - showActions={false} - showProgress={false} - /> - ))} - </div> - )} - - {hasNextPage && !isFetchingNextPage && !error && ( + {hasNextPage && !isFetchingNextPage && ( <div className="mt-4 flex items-center justify-center"> <button onClick={() => fetchNextPage()} @@ -78,7 +83,7 @@ export function AIExploreCourseListing() { </div> )} - {isFetchingNextPage && !error && ( + {isFetchingNextPage && ( <div className="mt-4 flex items-center justify-center gap-2"> <Loader2 className="size-4 animate-spin text-gray-400" diff --git a/src/components/GenerateCourse/UserCoursesList.tsx b/src/components/GenerateCourse/UserCoursesList.tsx index 943bead6f..ece352e6d 100644 --- a/src/components/GenerateCourse/UserCoursesList.tsx +++ b/src/components/GenerateCourse/UserCoursesList.tsx @@ -22,7 +22,7 @@ export function UserCoursesList() { const [showUpgradePopup, setShowUpgradePopup] = useState(false); const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({ - perPage: '10', + perPage: '21', currPage: '1', query: '', }); diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts index 4f90e2081..8b269124f 100644 --- a/src/queries/ai-course.ts +++ b/src/queries/ai-course.ts @@ -154,7 +154,7 @@ export function useListExploreAiCourses() { return httpGet<ListExploreAiCoursesResponse>( `/v1-list-explore-ai-courses`, { - perPage: '20', + perPage: '21', currPage: String(pageParam), }, ); From 2868fa3c27c052aee8aa1e043a0607ef8802ca37 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Fri, 11 Apr 2025 11:25:08 +0100 Subject: [PATCH 20/31] Implement pagination of ai tutor ai courses --- .../AITutor/AIExploreCourseListing.tsx | 107 ++++++++++-------- src/queries/ai-course.ts | 50 +++----- 2 files changed, 80 insertions(+), 77 deletions(-) diff --git a/src/components/AITutor/AIExploreCourseListing.tsx b/src/components/AITutor/AIExploreCourseListing.tsx index c8b25f560..97ddd2f5b 100644 --- a/src/components/AITutor/AIExploreCourseListing.tsx +++ b/src/components/AITutor/AIExploreCourseListing.tsx @@ -1,31 +1,53 @@ -import { useListExploreAiCourses } from '../../queries/ai-course'; +import { useQuery } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; -import { AlertCircle, Loader2 } from 'lucide-react'; +import { AlertCircle } from 'lucide-react'; import { AICourseCard } from '../GenerateCourse/AICourseCard'; import { AILoadingState } from './AILoadingState'; import { AITutorHeader } from './AITutorHeader'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { + listExploreAiCoursesOptions, + type ListExploreAiCoursesQuery, +} from '../../queries/ai-course'; +import { queryClient } from '../../stores/query-client'; +import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser'; +import { Pagination } from '../Pagination/Pagination'; export function AIExploreCourseListing() { const [isInitialLoading, setIsInitialLoading] = useState(true); const [showUpgradePopup, setShowUpgradePopup] = useState(false); - const { - data, - error, - fetchNextPage, - hasNextPage, - isFetching, - isFetchingNextPage, - status, - isLoading: isExploreAiCoursesLoading, - } = useListExploreAiCourses(); + const [pageState, setPageState] = useState<ListExploreAiCoursesQuery>({ + perPage: '21', + currPage: '1', + }); + + const { data: exploreAiCourses, isFetching: isExploreAiCoursesLoading } = + useQuery(listExploreAiCoursesOptions(pageState), queryClient); useEffect(() => { setIsInitialLoading(false); - }, [data]); + }, [exploreAiCourses]); + + const courses = exploreAiCourses?.data ?? []; - const courses = data?.pages.flatMap((page) => page.data) ?? []; + useEffect(() => { + const queryParams = getUrlParams(); + setPageState({ + ...pageState, + currPage: queryParams?.p || '1', + }); + }, []); + + useEffect(() => { + if (pageState?.currPage !== '1') { + setUrlParams({ + p: pageState?.currPage || '1', + }); + } else { + deleteUrlParam('p'); + } + }, [pageState]); if (isInitialLoading || isExploreAiCoursesLoading) { return ( @@ -36,12 +58,12 @@ export function AIExploreCourseListing() { ); } - if (error) { + if (!exploreAiCourses?.data) { return ( <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> <AlertCircle className="size-4 text-red-500" /> <p className="text-sm font-medium text-red-600"> - {error?.message ?? 'Error loading courses.'} + Error loading courses. </p> </div> ); @@ -59,39 +81,34 @@ export function AIExploreCourseListing() { /> {courses && courses.length > 0 && ( - <div className="grid grid-cols-3 gap-2"> - {courses.map((course) => ( - <AICourseCard - key={course._id} - course={course} - showActions={false} - showProgress={false} - /> - ))} - </div> - )} + <div className="flex flex-col gap-2"> + <div className="grid grid-cols-3 gap-2"> + {courses.map((course) => ( + <AICourseCard + key={course._id} + course={course} + showActions={false} + showProgress={false} + /> + ))} + </div> - {hasNextPage && !isFetchingNextPage && ( - <div className="mt-4 flex items-center justify-center"> - <button - onClick={() => fetchNextPage()} - disabled={isFetchingNextPage} - className="rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:opacity-50" - > - Load more - </button> + <Pagination + totalCount={exploreAiCourses?.totalCount || 0} + totalPages={exploreAiCourses?.totalPages || 0} + currPage={Number(exploreAiCourses?.currPage || 1)} + perPage={Number(exploreAiCourses?.perPage || 21)} + onPageChange={(page) => { + setPageState({ ...pageState, currPage: String(page) }); + }} + className="rounded-lg border border-gray-200 bg-white p-4" + /> </div> )} - {isFetchingNextPage && ( - <div className="mt-4 flex items-center justify-center gap-2"> - <Loader2 - className="size-4 animate-spin text-gray-400" - strokeWidth={2.5} - /> - <p className="text-sm font-medium text-gray-600"> - Loading more courses... - </p> + {!isExploreAiCoursesLoading && courses.length === 0 && ( + <div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> + <p className="text-sm text-gray-600">No courses found.</p> </div> )} </> diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts index 8b269124f..846bdb63e 100644 --- a/src/queries/ai-course.ts +++ b/src/queries/ai-course.ts @@ -1,7 +1,6 @@ import { httpGet } from '../lib/query-http'; import { isLoggedIn } from '../lib/jwt'; -import { queryOptions, useInfiniteQuery } from '@tanstack/react-query'; -import { queryClient } from '../stores/query-client'; +import { queryOptions } from '@tanstack/react-query'; export interface AICourseProgressDocument { _id: string; @@ -135,45 +134,32 @@ export function listFeaturedAiCoursesOptions( type ListExploreAiCoursesParams = {}; -type ListExploreAiCoursesQuery = { +export type ListExploreAiCoursesQuery = { perPage?: string; currPage?: string; }; type ListExploreAiCoursesResponse = { data: AICourseWithLessonCount[]; + totalCount: number; + totalPages: number; currPage: number; perPage: number; }; -export function useListExploreAiCourses() { - return useInfiniteQuery( - { - queryKey: ['explore-ai-courses'], - queryFn: ({ pageParam = 1 }) => { - return httpGet<ListExploreAiCoursesResponse>( - `/v1-list-explore-ai-courses`, - { - perPage: '21', - 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, +export function listExploreAiCoursesOptions( + params: ListExploreAiCoursesQuery = { + perPage: '21', + currPage: '1', + }, +) { + return { + queryKey: ['explore-ai-courses', params], + queryFn: () => { + return httpGet<ListExploreAiCoursesResponse>( + `/v1-list-explore-ai-courses`, + params, + ); }, - queryClient, - ); + }; } From b28eb5fecfa0236b5f6ba0c1136e3666ac8847c9 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Fri, 11 Apr 2025 12:50:01 +0100 Subject: [PATCH 21/31] AI explore page with search --- .../AITutor/AIExploreCourseListing.tsx | 72 +++++++++++-------- src/components/AITutor/AILoadingState.tsx | 2 +- src/components/AITutor/AITutorTallMessage.tsx | 2 +- src/queries/ai-course.ts | 6 +- 4 files changed, 48 insertions(+), 34 deletions(-) diff --git a/src/components/AITutor/AIExploreCourseListing.tsx b/src/components/AITutor/AIExploreCourseListing.tsx index 97ddd2f5b..8fbcc366f 100644 --- a/src/components/AITutor/AIExploreCourseListing.tsx +++ b/src/components/AITutor/AIExploreCourseListing.tsx @@ -1,6 +1,5 @@ import { useQuery } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; -import { AlertCircle } from 'lucide-react'; import { AICourseCard } from '../GenerateCourse/AICourseCard'; import { AILoadingState } from './AILoadingState'; import { AITutorHeader } from './AITutorHeader'; @@ -12,6 +11,9 @@ import { import { queryClient } from '../../stores/query-client'; import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser'; import { Pagination } from '../Pagination/Pagination'; +import { AICourseSearch } from '../GenerateCourse/AICourseSearch'; +import { AITutorTallMessage } from './AITutorTallMessage'; +import { BookOpen } from 'lucide-react'; export function AIExploreCourseListing() { const [isInitialLoading, setIsInitialLoading] = useState(true); @@ -20,10 +22,14 @@ export function AIExploreCourseListing() { const [pageState, setPageState] = useState<ListExploreAiCoursesQuery>({ perPage: '21', currPage: '1', + query: '', }); - const { data: exploreAiCourses, isFetching: isExploreAiCoursesLoading } = - useQuery(listExploreAiCoursesOptions(pageState), queryClient); + const { + data: exploreAiCourses, + isFetching: isExploreAiCoursesLoading, + isRefetching: isExploreAiCoursesRefetching, + } = useQuery(listExploreAiCoursesOptions(pageState), queryClient); useEffect(() => { setIsInitialLoading(false); @@ -49,26 +55,6 @@ export function AIExploreCourseListing() { } }, [pageState]); - if (isInitialLoading || isExploreAiCoursesLoading) { - return ( - <AILoadingState - title="Loading courses" - subtitle="This may take a moment..." - /> - ); - } - - if (!exploreAiCourses?.data) { - return ( - <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> - <AlertCircle className="size-4 text-red-500" /> - <p className="text-sm font-medium text-red-600"> - Error loading courses. - </p> - </div> - ); - } - return ( <> {showUpgradePopup && ( @@ -78,9 +64,27 @@ export function AIExploreCourseListing() { <AITutorHeader title="Explore Courses" onUpgradeClick={() => setShowUpgradePopup(true)} - /> + > + <AICourseSearch + value={pageState?.query || ''} + onChange={(value) => { + setPageState({ + ...pageState, + query: value, + currPage: '1', + }); + }} + /> + </AITutorHeader> + + {(isInitialLoading || isExploreAiCoursesLoading) && ( + <AILoadingState + title="Loading courses" + subtitle="This may take a moment..." + /> + )} - {courses && courses.length > 0 && ( + {!isExploreAiCoursesLoading && courses && courses.length > 0 && ( <div className="flex flex-col gap-2"> <div className="grid grid-cols-3 gap-2"> {courses.map((course) => ( @@ -106,11 +110,19 @@ export function AIExploreCourseListing() { </div> )} - {!isExploreAiCoursesLoading && courses.length === 0 && ( - <div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> - <p className="text-sm text-gray-600">No courses found.</p> - </div> - )} + {!isInitialLoading && + !isExploreAiCoursesLoading && + courses.length === 0 && ( + <AITutorTallMessage + title="No courses found" + subtitle="Try a different search or check back later." + icon={BookOpen} + buttonText="Create your first course" + onButtonClick={() => { + window.location.href = '/ai'; + }} + /> + )} </> ); } diff --git a/src/components/AITutor/AILoadingState.tsx b/src/components/AITutor/AILoadingState.tsx index 66ed9de4e..cdaad1020 100644 --- a/src/components/AITutor/AILoadingState.tsx +++ b/src/components/AITutor/AILoadingState.tsx @@ -9,7 +9,7 @@ export function AILoadingState(props: AILoadingStateProps) { const { title, subtitle } = props; return ( - <div className="flex min-h-full w-full flex-col items-center justify-center gap-4 rounded-lg border border-gray-200 bg-white p-8"> + <div className="flex flex-grow w-full flex-col items-center justify-center gap-4 rounded-lg border border-gray-200 bg-white p-8"> <div className="relative"> <Loader2 className="size-12 animate-spin text-gray-300" /> <div className="absolute inset-0 flex items-center justify-center"> diff --git a/src/components/AITutor/AITutorTallMessage.tsx b/src/components/AITutor/AITutorTallMessage.tsx index a0000a3ff..a990fe885 100644 --- a/src/components/AITutor/AITutorTallMessage.tsx +++ b/src/components/AITutor/AITutorTallMessage.tsx @@ -12,7 +12,7 @@ export function AITutorTallMessage(props: AITutorTallMessageProps) { const { title, subtitle, icon: Icon, buttonText, onButtonClick } = props; return ( - <div className="flex min-h-full flex-grow flex-col items-center justify-center rounded-lg"> + <div className="flex flex-grow flex-col items-center justify-center rounded-lg border border-gray-200 bg-white p-8"> <Icon className="size-12 text-gray-300" /> <div className="my-4 text-center"> <h2 className="mb-2 text-xl font-semibold">{title}</h2> diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts index 846bdb63e..04ffa942c 100644 --- a/src/queries/ai-course.ts +++ b/src/queries/ai-course.ts @@ -83,7 +83,7 @@ type ListUserAiCoursesResponse = { export function listUserAiCoursesOptions( params: ListUserAiCoursesQuery = { - perPage: '10', + perPage: '21', currPage: '1', query: '', }, @@ -117,7 +117,7 @@ type ListFeaturedAiCoursesResponse = { export function listFeaturedAiCoursesOptions( params: ListFeaturedAiCoursesQuery = { - perPage: '10', + perPage: '21', currPage: '1', }, ) { @@ -137,6 +137,7 @@ type ListExploreAiCoursesParams = {}; export type ListExploreAiCoursesQuery = { perPage?: string; currPage?: string; + query?: string; }; type ListExploreAiCoursesResponse = { @@ -151,6 +152,7 @@ export function listExploreAiCoursesOptions( params: ListExploreAiCoursesQuery = { perPage: '21', currPage: '1', + query: '', }, ) { return { From 1970e0c92ee2cdb12a4bda3ba16548fa26d0fa77 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Fri, 11 Apr 2025 17:06:58 +0100 Subject: [PATCH 22/31] Fix pagination of tutor --- .../AITutor/AIFeaturedCoursesListing.tsx | 108 ++++++++---------- src/components/GenerateCourse/AICourse.tsx | 3 +- .../GenerateCourse/UserCoursesList.tsx | 56 ++++----- 3 files changed, 72 insertions(+), 95 deletions(-) diff --git a/src/components/AITutor/AIFeaturedCoursesListing.tsx b/src/components/AITutor/AIFeaturedCoursesListing.tsx index 2b7fa3468..0023cda29 100644 --- a/src/components/AITutor/AIFeaturedCoursesListing.tsx +++ b/src/components/AITutor/AIFeaturedCoursesListing.tsx @@ -8,18 +8,18 @@ import { useEffect, useState } from 'react'; import { getUrlParams, setUrlParams, deleteUrlParam } from '../../lib/browser'; import { AICourseCard } from '../GenerateCourse/AICourseCard'; import { Pagination } from '../Pagination/Pagination'; -import { AILoadingState } from './AILoadingState'; -import { AITutorTallMessage } from './AITutorTallMessage'; -import { BookOpen } from 'lucide-react'; import { AITutorHeader } from './AITutorHeader'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { AITutorTallMessage } from './AITutorTallMessage'; +import { BookOpen } from 'lucide-react'; +import { AILoadingState } from './AILoadingState'; export function AIFeaturedCoursesListing() { const [isInitialLoading, setIsInitialLoading] = useState(true); const [showUpgradePopup, setShowUpgradePopup] = useState(false); const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({ - perPage: '20', + perPage: '21', currPage: '1', }); @@ -51,31 +51,8 @@ export function AIFeaturedCoursesListing() { } }, [pageState]); - if (isInitialLoading || isFeaturedAiCoursesLoading) { - return ( - <AILoadingState - title="Loading featured courses" - subtitle="This may take a moment..." - /> - ); - } - - if (courses.length === 0) { - return ( - <AITutorTallMessage - title="No featured courses" - subtitle="There are no featured courses available at the moment." - icon={BookOpen} - buttonText="Browse all courses" - onButtonClick={() => { - window.location.href = '/ai'; - }} - /> - ); - } - return ( - <div className="w-full"> + <> {showUpgradePopup && ( <UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} /> )} @@ -85,41 +62,54 @@ export function AIFeaturedCoursesListing() { onUpgradeClick={() => setShowUpgradePopup(true)} /> - {!isFeaturedAiCoursesLoading && courses && courses.length > 0 && ( - <div className="flex flex-col gap-2"> - <div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3"> - {courses.map((course) => ( - <AICourseCard - key={course._id} - course={course} - showActions={false} - showProgress={false} - /> - ))} - </div> - - <Pagination - totalCount={featuredAiCourses?.totalCount || 0} - totalPages={featuredAiCourses?.totalPages || 0} - currPage={Number(featuredAiCourses?.currPage || 1)} - perPage={Number(featuredAiCourses?.perPage || 10)} - onPageChange={(page) => { - setPageState({ ...pageState, currPage: String(page) }); - }} - className="rounded-lg border border-gray-200 bg-white p-4" - /> - </div> + {(isFeaturedAiCoursesLoading || isInitialLoading) && ( + <AILoadingState + title="Loading featured courses" + subtitle="This may take a moment..." + /> )} {!isFeaturedAiCoursesLoading && - (featuredAiCourses?.data?.length || 0 > 0) && - courses.length === 0 && ( - <div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> - <p className="text-sm text-gray-600"> - No courses match your search. - </p> + !isInitialLoading && + courses.length > 0 && ( + <div className="flex flex-col gap-2"> + <div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3"> + {courses.map((course) => ( + <AICourseCard + key={course._id} + course={course} + showActions={false} + showProgress={false} + /> + ))} + </div> + + <Pagination + totalCount={featuredAiCourses?.totalCount || 0} + totalPages={featuredAiCourses?.totalPages || 0} + currPage={Number(featuredAiCourses?.currPage || 1)} + perPage={Number(featuredAiCourses?.perPage || 10)} + onPageChange={(page) => { + setPageState({ ...pageState, currPage: String(page) }); + }} + className="rounded-lg border border-gray-200 bg-white p-4" + /> </div> )} - </div> + + {!isFeaturedAiCoursesLoading && + !isInitialLoading && + courses.length === 0 && ( + <AITutorTallMessage + title="No featured courses" + subtitle="There are no featured courses available at the moment." + icon={BookOpen} + buttonText="Browse all courses" + onButtonClick={() => { + window.location.href = '/ai'; + }} + /> + )} + </> ); } diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index d100a7d97..eba3cecd5 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -92,6 +92,7 @@ export function AICourse(props: AICourseProps) { id="keyword" type="text" value={keyword} + autoFocus={true} onChange={(e) => setKeyword(e.target.value)} onKeyDown={handleKeyDown} placeholder="e.g. JavaScript Promises, React Hooks, Go Routines etc" @@ -126,7 +127,7 @@ export function AICourse(props: AICourseProps) { type="submit" disabled={!keyword.trim()} className={cn( - 'flex items-center justify-center rounded-full px-4 py-1 text-white transition-colors text-sm', + 'flex items-center justify-center rounded-full px-4 py-1 text-sm text-white transition-colors', !keyword.trim() ? 'cursor-not-allowed bg-gray-400' : 'bg-black hover:bg-gray-800', diff --git a/src/components/GenerateCourse/UserCoursesList.tsx b/src/components/GenerateCourse/UserCoursesList.tsx index ece352e6d..bef5f21cb 100644 --- a/src/components/GenerateCourse/UserCoursesList.tsx +++ b/src/components/GenerateCourse/UserCoursesList.tsx @@ -9,13 +9,13 @@ import { type ListUserAiCoursesQuery, } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; -import { AILoadingState } from '../AITutor/AILoadingState'; import { AITutorHeader } from '../AITutor/AITutorHeader'; import { AITutorTallMessage } from '../AITutor/AITutorTallMessage'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { Pagination } from '../Pagination/Pagination'; import { AICourseCard } from './AICourseCard'; import { AICourseSearch } from './AICourseSearch'; +import { AILoadingState } from '../AITutor/AILoadingState'; export function UserCoursesList() { const [isInitialLoading, setIsInitialLoading] = useState(true); @@ -60,16 +60,7 @@ export function UserCoursesList() { } }, [pageState]); - if (isInitialLoading || isUserAiCoursesLoading) { - return ( - <AILoadingState - title="Loading your courses" - subtitle="This may take a moment..." - /> - ); - } - - if (!isLoggedIn()) { + if (!isInitialLoading && !isLoggedIn()) { return ( <AITutorTallMessage title="Sign up or login" @@ -83,20 +74,6 @@ export function UserCoursesList() { ); } - if (courses.length === 0) { - return ( - <AITutorTallMessage - title="No courses found" - subtitle="You haven't generated any courses yet." - icon={BookOpen} - buttonText="Create your first course" - onButtonClick={() => { - window.location.href = '/ai'; - }} - /> - ); - } - return ( <> {showUpgradePopup && ( @@ -119,7 +96,14 @@ export function UserCoursesList() { /> </AITutorHeader> - {!isUserAiCoursesLoading && courses && courses.length > 0 && ( + {(isUserAiCoursesLoading || isInitialLoading) && ( + <AILoadingState + title="Loading your courses" + subtitle="This may take a moment..." + /> + )} + + {!isUserAiCoursesLoading && !isInitialLoading && courses.length > 0 && ( <div className="flex flex-col gap-2"> <div className="grid grid-cols-3 gap-2"> {courses.map((course) => ( @@ -140,15 +124,17 @@ export function UserCoursesList() { </div> )} - {!isUserAiCoursesLoading && - (userAiCourses?.data?.length || 0 > 0) && - courses.length === 0 && ( - <div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> - <p className="text-sm text-gray-600"> - No courses match your search. - </p> - </div> - )} + {!isUserAiCoursesLoading && !isInitialLoading && courses.length === 0 && ( + <AITutorTallMessage + title="No courses found" + subtitle="You haven't generated any courses yet." + icon={BookOpen} + buttonText="Create your first course" + onButtonClick={() => { + window.location.href = '/ai'; + }} + /> + )} </> ); } From 618e4c12335b403a30211399e5fc8c4ddd9f9c28 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Fri, 11 Apr 2025 17:18:10 +0100 Subject: [PATCH 23/31] Update tutor header design --- src/components/AITutor/AITutorHeader.tsx | 2 +- src/components/GenerateCourse/UserCoursesList.tsx | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/AITutor/AITutorHeader.tsx b/src/components/AITutor/AITutorHeader.tsx index 77d7c881b..154a87865 100644 --- a/src/components/AITutor/AITutorHeader.tsx +++ b/src/components/AITutor/AITutorHeader.tsx @@ -21,7 +21,7 @@ export function AITutorHeader(props: AITutorHeaderProps) { return ( <div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1"> <div className="flex items-center gap-2"> - <h2 className="text-lg font-semibold">{title}</h2> + <h2 className="relative flex-shrink-0 top-0 lg:top-1 text-lg font-semibold">{title}</h2> </div> <div className="flex items-center gap-2"> diff --git a/src/components/GenerateCourse/UserCoursesList.tsx b/src/components/GenerateCourse/UserCoursesList.tsx index bef5f21cb..6a64f34ef 100644 --- a/src/components/GenerateCourse/UserCoursesList.tsx +++ b/src/components/GenerateCourse/UserCoursesList.tsx @@ -60,7 +60,16 @@ export function UserCoursesList() { } }, [pageState]); - if (!isInitialLoading && !isLoggedIn()) { + if (isUserAiCoursesLoading || isInitialLoading) { + return ( + <AILoadingState + title="Loading your courses" + subtitle="This may take a moment..." + /> + ); + } + + if (!isLoggedIn()) { return ( <AITutorTallMessage title="Sign up or login" @@ -105,7 +114,7 @@ export function UserCoursesList() { {!isUserAiCoursesLoading && !isInitialLoading && courses.length > 0 && ( <div className="flex flex-col gap-2"> - <div className="grid grid-cols-3 gap-2"> + <div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3"> {courses.map((course) => ( <AICourseCard key={course._id} course={course} /> ))} From ec458f2fd2b3f1e82a92e9dfc2e86495cb159bd2 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Fri, 11 Apr 2025 18:04:47 +0100 Subject: [PATCH 24/31] Responsiveness of AI --- .../AITutor/AIExploreCourseListing.tsx | 2 +- src/components/AITutor/AITutorLayout.tsx | 33 +++++- src/components/AITutor/AITutorSidebar.tsx | 103 +++++++++++------- src/components/GenerateCourse/AICourse.tsx | 27 ++++- 4 files changed, 112 insertions(+), 53 deletions(-) diff --git a/src/components/AITutor/AIExploreCourseListing.tsx b/src/components/AITutor/AIExploreCourseListing.tsx index 8fbcc366f..15ac7c9ef 100644 --- a/src/components/AITutor/AIExploreCourseListing.tsx +++ b/src/components/AITutor/AIExploreCourseListing.tsx @@ -86,7 +86,7 @@ export function AIExploreCourseListing() { {!isExploreAiCoursesLoading && courses && courses.length > 0 && ( <div className="flex flex-col gap-2"> - <div className="grid grid-cols-3 gap-2"> + <div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3"> {courses.map((course) => ( <AICourseCard key={course._id} diff --git a/src/components/AITutor/AITutorLayout.tsx b/src/components/AITutor/AITutorLayout.tsx index c7663c48f..b50849495 100644 --- a/src/components/AITutor/AITutorLayout.tsx +++ b/src/components/AITutor/AITutorLayout.tsx @@ -1,4 +1,7 @@ +import { Menu } from 'lucide-react'; +import { useState } from 'react'; import { AITutorSidebar, type AITutorTab } from './AITutorSidebar'; +import { RoadmapLogoIcon } from '../ReactIcons/RoadmapLogo'; type AITutorLayoutProps = { children: React.ReactNode; @@ -8,12 +11,32 @@ type AITutorLayoutProps = { export function AITutorLayout(props: AITutorLayoutProps) { const { children, activeTab } = props; + const [isSidebarFloating, setIsSidebarFloating] = useState(false); + return ( - <div className="flex flex-grow flex-row"> - <AITutorSidebar activeTab={activeTab} /> - <div className="flex flex-grow h-screen overflow-y-scroll flex-col bg-gray-100 px-4 py-4"> - {children} + <> + <div className="flex flex-row items-center justify-between border-b border-slate-200 px-4 py-3 lg:hidden"> + <a href="/" className="flex flex-row items-center gap-1.5"> + <RoadmapLogoIcon className="size-6 text-gray-500" color="black" /> + </a> + <button + className="flex flex-row items-center gap-1" + onClick={() => setIsSidebarFloating(!isSidebarFloating)} + > + <Menu className="size-5 text-gray-500" /> + </button> + </div> + + <div className="flex flex-grow flex-row"> + <AITutorSidebar + onClose={() => setIsSidebarFloating(false)} + isFloating={isSidebarFloating} + activeTab={activeTab} + /> + <div className="flex flex-grow flex-col overflow-y-scroll bg-gray-100 p-3 lg:px-4 lg:py-4"> + {children} + </div> </div> - </div> + </> ); } diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index c6b1a7aa5..5ee4fccc2 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -1,8 +1,10 @@ -import { BookOpen, Compass, Plus, Star, Users2 } from 'lucide-react'; +import { BookOpen, Compass, Plus, Star, X } from 'lucide-react'; import { AITutorLogo } from '../ReactIcons/AITutorLogo'; type AITutorSidebarProps = { + isFloating: boolean; activeTab: AITutorTab; + onClose: () => void; }; const sidebarItems = [ @@ -35,49 +37,68 @@ const sidebarItems = [ export type AITutorTab = (typeof sidebarItems)[number]['key']; export function AITutorSidebar(props: AITutorSidebarProps) { - const { activeTab } = props; + const { activeTab, isFloating, onClose } = props; return ( - <aside className="hidden w-[255px] shrink-0 border-r border-slate-200 md:block"> - <div className="flex flex-col items-start justify-center px-6 py-5"> - <div className="flex flex-row items-center gap-1"> - <AITutorLogo className="size-11 text-gray-500" color="black" /> + <> + <aside + className={`w-[255px] shrink-0 border-r border-slate-200 ${ + isFloating + ? 'fixed top-0 bottom-0 left-0 z-50 block border-r-0 bg-white shadow-xl' + : 'hidden lg:block' + }`} + > + {isFloating && ( + <button className="absolute top-3 right-3" onClick={onClose}> + <X + strokeWidth={3} + className="size-3.5 text-gray-400 hover:text-black" + /> + </button> + )} + <div className="flex flex-col items-start justify-center px-6 py-5"> + <div className="flex flex-row items-center gap-1"> + <AITutorLogo className="size-11 text-gray-500" color="black" /> + </div> + <div className="my-3 flex flex-col"> + <h2 className="-mb-px text-base font-semibold text-black"> + AI Tutor + </h2> + <span className="text-xs text-gray-500"> + by{' '} + <a href="/" className="underline-offset-2 hover:underline"> + roadmap.sh + </a> + </span> + </div> + <p className="max-w-[150px] text-xs text-gray-500"> + Your personalized learning companion for any topic + </p> </div> - <div className="my-3 flex flex-col"> - <h2 className="-mb-px text-base font-semibold text-black"> - AI Tutor - </h2> - <span className="text-xs text-gray-500"> - by{' '} - <a href="/" className="underline-offset-2 hover:underline"> - roadmap.sh - </a> - </span> - </div> - <p className="max-w-[150px] text-xs text-gray-500"> - Your personalized learning companion for any topic - </p> - </div> - <ul className="space-y-1"> - {sidebarItems.map((item) => ( - <li key={item.key}> - <a - href={item.href} - className={`font-regular flex w-full items-center border-r-2 px-5 py-2 text-sm transition-all ${ - activeTab === item.key - ? 'border-r-black bg-gray-100 text-black' - : 'border-r-transparent text-gray-500 hover:border-r-gray-300' - }`} - > - <span className="flex grow items-center"> - <item.icon className="mr-2 size-4" /> - {item.label} - </span> - </a> - </li> - ))} - </ul> - </aside> + <ul className="space-y-1"> + {sidebarItems.map((item) => ( + <li key={item.key}> + <a + href={item.href} + className={`font-regular flex w-full items-center border-r-2 px-5 py-2 text-sm transition-all ${ + activeTab === item.key + ? 'border-r-black bg-gray-100 text-black' + : 'border-r-transparent text-gray-500 hover:border-r-gray-300' + }`} + > + <span className="flex grow items-center"> + <item.icon className="mr-2 size-4" /> + {item.label} + </span> + </a> + </li> + ))} + </ul> + </aside> + {isFloating && ( + <div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} /> + )} + </> ); } diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index eba3cecd5..2e6fffb70 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -72,11 +72,11 @@ export function AICourse(props: AICourseProps) { } return ( - <div className="mx-auto flex w-full max-w-3xl flex-grow flex-col justify-center"> - <h1 className="mb-2.5 text-center text-4xl font-semibold max-sm:mb-2 max-sm:text-left max-sm:text-xl"> + <div className="mx-auto flex w-full max-w-3xl flex-grow flex-col pt-4 md:justify-center md:pt-10 lg:pt-0"> + <h1 className="mb-0.5 text-center text-4xl font-semibold max-md:text-left max-md:text-xl lg:mb-3"> What can I help you learn? </h1> - <p className="mb-6 text-center text-lg text-gray-600 max-sm:hidden max-sm:text-left max-sm:text-sm"> + <p className="mb-3 text-balance text-center text-lg text-gray-600 max-md:text-left max-md:text-sm lg:mb-6"> Enter a topic below to generate a personalized course for it </p> @@ -100,7 +100,7 @@ export function AICourse(props: AICourseProps) { maxLength={50} /> - <div className="flex flex-row items-center justify-between gap-2 px-4 pb-4"> + <div className="flex flex-col items-start justify-between gap-2 px-4 pb-4 md:flex-row md:items-center"> <div className="flex flex-row items-center gap-2"> <div className="flex flex-row gap-2"> <DifficultyDropdown @@ -119,7 +119,8 @@ export function AICourse(props: AICourseProps) { className="mr-1" id="fine-tune-checkbox" /> - Explain more for a better course + Explain more + <span className="hidden md:inline"> for a better course</span> </label> </div> @@ -127,7 +128,7 @@ export function AICourse(props: AICourseProps) { type="submit" disabled={!keyword.trim()} className={cn( - 'flex items-center justify-center rounded-full px-4 py-1 text-sm text-white transition-colors', + 'hidden items-center justify-center rounded-full px-4 py-1 text-sm text-white transition-colors md:flex', !keyword.trim() ? 'cursor-not-allowed bg-gray-400' : 'bg-black hover:bg-gray-800', @@ -148,6 +149,20 @@ export function AICourse(props: AICourseProps) { setGoal={setGoal} setCustomInstructions={setCustomInstructions} /> + + <button + type="submit" + disabled={!keyword.trim()} + className={cn( + 'mx-4 mb-3 flex items-center justify-center rounded-full px-4 py-1 text-sm text-white transition-colors md:hidden', + !keyword.trim() + ? 'cursor-not-allowed bg-gray-400' + : 'bg-black hover:bg-gray-800', + )} + > + <WandIcon size={18} className="mr-2" /> + Generate Course + </button> </form> </div> </div> From fd9b388834b3891c5acc6e7205a15b63d9e2b49b Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Fri, 11 Apr 2025 18:22:51 +0100 Subject: [PATCH 25/31] Fork alert changes --- src/components/GenerateCourse/AICourseContent.tsx | 12 ------------ src/components/GenerateCourse/ForkCourseAlert.tsx | 4 ++-- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index 820b33197..e2abc32dc 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -8,7 +8,6 @@ import { Map, MessageCircleOffIcon, MessageCircleIcon, - GitForkIcon, } from 'lucide-react'; import { useEffect, useState } from 'react'; import { type AiCourse } from '../../lib/ai'; @@ -328,17 +327,6 @@ export function AICourseContent(props: AICourseContentProps) { onUpgrade={() => setShowUpgradeModal(true)} onShowLimits={() => setShowAILimitsPopup(true)} /> - {isForkable && ( - <button - className="hidden items-center justify-center gap-1 rounded-md bg-yellow-400 px-4 py-1 text-sm font-medium underline-offset-2 hover:bg-yellow-500 lg:flex" - onClick={() => { - setIsForkingCourse(true); - }} - > - <GitForkIcon className="size-4" /> - Fork - </button> - )} </div> </div> </header> diff --git a/src/components/GenerateCourse/ForkCourseAlert.tsx b/src/components/GenerateCourse/ForkCourseAlert.tsx index e7280b94a..7f3f7b0dc 100644 --- a/src/components/GenerateCourse/ForkCourseAlert.tsx +++ b/src/components/GenerateCourse/ForkCourseAlert.tsx @@ -17,13 +17,13 @@ export function ForkCourseAlert(props: ForkCourseAlertProps) { } return ( - <div className="mb-4 flex items-center justify-between gap-2 rounded-lg bg-yellow-200 p-3 text-black"> + <div className="mb-3.5 lg:-mt-2.5 max-w-5xl mx-auto flex items-center justify-between gap-2 rounded-lg bg-yellow-200 p-3 text-black"> <p className="text-sm text-balance"> To start tracking your progress, you can fork the course. </p> <button - className="flex shrink-0 items-center gap-2 rounded-md bg-yellow-400 p-1 px-2 text-sm text-black" + className="flex shrink-0 items-center gap-2 rounded-md bg-yellow-400 py-1.5 px-3 text-sm text-black" onClick={onForkCourse} > <GitForkIcon className="size-3.5" /> From a75f97360a21b4a4afdd85463b1d1a69134d9636 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Fri, 11 Apr 2025 19:00:25 +0100 Subject: [PATCH 26/31] Responsiveness of actions --- .../GenerateCourse/AICourseContent.tsx | 7 +++++-- .../GenerateCourse/AICourseLesson.tsx | 17 +++++++++++++++-- .../GenerateCourse/ForkCourseAlert.tsx | 14 ++++++++++---- .../GenerateCourse/RegenerateLesson.tsx | 2 +- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index e2abc32dc..462404049 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -248,7 +248,10 @@ export function AICourseContent(props: AICourseContentProps) { aria-label="Back to generator" > <ChevronLeft className="size-4" strokeWidth={2.5} /> - Back {isViewingLesson ? 'to Outline' : 'to AI Tutor'} + Back{' '} + <span className="hidden lg:inline"> + {isViewingLesson ? 'to Outline' : 'to AI Tutor'} + </span> </a> <div className="flex items-center gap-2"> <div className="flex flex-row lg:hidden"> @@ -439,7 +442,6 @@ export function AICourseContent(props: AICourseContentProps) { courseSlug && (viewMode === 'outline' || viewMode === 'roadmap') && ( <ForkCourseAlert - courseSlug={courseSlug} creatorId={creatorId} onForkCourse={() => { setIsForkingCourse(true); @@ -459,6 +461,7 @@ export function AICourseContent(props: AICourseContentProps) { {viewMode === 'module' && ( <AICourseLesson courseSlug={courseSlug!} + creatorId={creatorId} progress={aiCourseProgress} activeModuleIndex={activeModuleIndex} totalModules={totalModules} diff --git a/src/components/GenerateCourse/AICourseLesson.tsx b/src/components/GenerateCourse/AICourseLesson.tsx index 3a10b5680..dd4a443a2 100644 --- a/src/components/GenerateCourse/AICourseLesson.tsx +++ b/src/components/GenerateCourse/AICourseLesson.tsx @@ -3,6 +3,7 @@ import { CheckIcon, ChevronLeft, ChevronRight, + GitForkIcon, Loader2Icon, LockIcon, MessageCircleIcon, @@ -55,6 +56,7 @@ function getQuestionsFromResult(result: string) { type AICourseLessonProps = { courseSlug: string; progress: string[]; + creatorId?: string; activeModuleIndex: number; totalModules: number; @@ -79,6 +81,7 @@ export function AICourseLesson(props: AICourseLessonProps) { const { courseSlug, progress = [], + creatorId, activeModuleIndex, totalModules, @@ -298,13 +301,13 @@ export function AICourseLesson(props: AICourseLessonProps) { </div> )} - <div className="mb-4 flex items-center justify-between"> + <div className="mb-4 flex max-sm:flex-col-reverse justify-between"> <div className="text-sm text-gray-500"> Lesson {activeLessonIndex + 1} of {totalLessons} </div> {!isGenerating && !isLoading && ( - <div className="absolute top-2 right-2 flex items-center justify-between gap-2 lg:top-6 lg:right-6"> + <div className="md:absolute top-2 right-2 flex items-center max-sm:justify-end gap-2 lg:top-6 lg:right-6 mb-3"> <button onClick={() => setIsAIChatsOpen(!isAIChatsOpen)} className="rounded-full p-1 text-gray-400 hover:text-black max-lg:hidden" @@ -323,6 +326,16 @@ export function AICourseLesson(props: AICourseLessonProps) { isForkable={isForkable} onForkCourse={onForkCourse} /> + + {isForkable && ( + <button + onClick={onForkCourse} + className="flex items-center gap-1.5 rounded-full border bg-gray-100 py-1 pr-4 pl-3 text-sm text-black hover:bg-gray-200 disabled:opacity-50 max-lg:text-xs" + > + <GitForkIcon className="size-3.5" /> + Fork Course + </button> + )} <button disabled={isLoading || isTogglingDone} className={cn( diff --git a/src/components/GenerateCourse/ForkCourseAlert.tsx b/src/components/GenerateCourse/ForkCourseAlert.tsx index 7f3f7b0dc..01fdfc8c3 100644 --- a/src/components/GenerateCourse/ForkCourseAlert.tsx +++ b/src/components/GenerateCourse/ForkCourseAlert.tsx @@ -1,14 +1,15 @@ import { GitForkIcon } from 'lucide-react'; import { getUser } from '../../lib/jwt'; +import { cn } from '../../lib/classname'; type ForkCourseAlertProps = { - courseSlug: string; + className?: string; creatorId?: string; onForkCourse: () => void; }; export function ForkCourseAlert(props: ForkCourseAlertProps) { - const { courseSlug, creatorId, onForkCourse } = props; + const { creatorId, onForkCourse, className = '' } = props; const currentUser = getUser(); @@ -17,13 +18,18 @@ export function ForkCourseAlert(props: ForkCourseAlertProps) { } return ( - <div className="mb-3.5 lg:-mt-2.5 max-w-5xl mx-auto flex items-center justify-between gap-2 rounded-lg bg-yellow-200 p-3 text-black"> + <div + className={cn( + 'mx-auto mb-3.5 flex max-w-5xl items-center justify-between gap-2 rounded-lg bg-yellow-200 p-3 text-black lg:-mt-2.5', + className, + )} + > <p className="text-sm text-balance"> To start tracking your progress, you can fork the course. </p> <button - className="flex shrink-0 items-center gap-2 rounded-md bg-yellow-400 py-1.5 px-3 text-sm text-black" + className="flex shrink-0 items-center gap-2 rounded-md bg-yellow-400 px-3 py-1.5 text-sm text-black" onClick={onForkCourse} > <GitForkIcon className="size-3.5" /> diff --git a/src/components/GenerateCourse/RegenerateLesson.tsx b/src/components/GenerateCourse/RegenerateLesson.tsx index 2427c119c..507d106b9 100644 --- a/src/components/GenerateCourse/RegenerateLesson.tsx +++ b/src/components/GenerateCourse/RegenerateLesson.tsx @@ -49,7 +49,7 @@ export function RegenerateLesson(props: RegenerateLessonProps) { /> )} - <div className="relative mr-2 flex items-center" ref={ref}> + <div className="relative lg:mr-1 flex items-center" ref={ref}> <button className={cn('rounded-full p-1 text-gray-400 hover:text-black', { 'text-black': isDropdownVisible, From 926a08ac6c60a79ff7ea64c74a05171ae68a8ab2 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Fri, 11 Apr 2025 19:06:34 +0100 Subject: [PATCH 27/31] Forking functionality changes --- .../GenerateCourse/ForkCourseConfirmation.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/GenerateCourse/ForkCourseConfirmation.tsx b/src/components/GenerateCourse/ForkCourseConfirmation.tsx index b0c19f67b..d91a8aee8 100644 --- a/src/components/GenerateCourse/ForkCourseConfirmation.tsx +++ b/src/components/GenerateCourse/ForkCourseConfirmation.tsx @@ -48,13 +48,19 @@ export function ForkCourseConfirmation(props: ForkCourseConfirmationProps) { ); return ( - <Modal onClose={isPending ? () => {} : onClose}> + <Modal + onClose={isPending ? () => {} : onClose} + wrapperClassName="h-auto items-start" + overlayClassName="items-start md:items-center" + > <div className="flex flex-col items-center p-4 pt-8"> - <GitForkIcon className="size-14 text-gray-500" /> - <p className="mt-2 text-xl font-medium">Fork Course</p> - <p className="mt-1 text-center text-balance text-gray-500"> - Forking this course will create a new course with the same content. - </p> + <GitForkIcon className="size-14 text-gray-300" /> + <div className="my-5 text-center"> + <p className="text-xl font-semibold">Fork Course</p> + <p className="mt-1 text-center text-balance text-gray-500"> + Create a copy of this course + </p> + </div> <div className="mt-4 grid w-full grid-cols-2 gap-2"> <button From 5a4cdc849f33b4dce379247742a8b211c9b1168e Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Fri, 11 Apr 2025 19:24:37 +0100 Subject: [PATCH 28/31] Fork confirmation changes --- .../GenerateCourse/ForkCourseAlert.tsx | 4 +- .../GenerateCourse/ForkCourseConfirmation.tsx | 41 +++++++++++-------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/components/GenerateCourse/ForkCourseAlert.tsx b/src/components/GenerateCourse/ForkCourseAlert.tsx index 01fdfc8c3..b00658246 100644 --- a/src/components/GenerateCourse/ForkCourseAlert.tsx +++ b/src/components/GenerateCourse/ForkCourseAlert.tsx @@ -25,11 +25,11 @@ export function ForkCourseAlert(props: ForkCourseAlertProps) { )} > <p className="text-sm text-balance"> - To start tracking your progress, you can fork the course. + Fork the course to track progress and make changes to the course. </p> <button - className="flex shrink-0 items-center gap-2 rounded-md bg-yellow-400 px-3 py-1.5 text-sm text-black" + className="flex shrink-0 items-center gap-2 rounded-md hover:bg-yellow-500 bg-yellow-400 px-3 py-1.5 text-sm text-black" onClick={onForkCourse} > <GitForkIcon className="size-3.5" /> diff --git a/src/components/GenerateCourse/ForkCourseConfirmation.tsx b/src/components/GenerateCourse/ForkCourseConfirmation.tsx index d91a8aee8..7073a2a8a 100644 --- a/src/components/GenerateCourse/ForkCourseConfirmation.tsx +++ b/src/components/GenerateCourse/ForkCourseConfirmation.tsx @@ -1,4 +1,4 @@ -import { GitForkIcon, Loader2Icon } from 'lucide-react'; +import { Copy, GitForkIcon, Loader2Icon } from 'lucide-react'; import { Modal } from '../Modal'; import type { AICourseDocument } from '../../queries/ai-course'; import { useMutation } from '@tanstack/react-query'; @@ -50,37 +50,46 @@ export function ForkCourseConfirmation(props: ForkCourseConfirmationProps) { return ( <Modal onClose={isPending ? () => {} : onClose} - wrapperClassName="h-auto items-start" + wrapperClassName="h-auto items-start max-w-md w-full" overlayClassName="items-start md:items-center" > - <div className="flex flex-col items-center p-4 pt-8"> - <GitForkIcon className="size-14 text-gray-300" /> - <div className="my-5 text-center"> - <p className="text-xl font-semibold">Fork Course</p> - <p className="mt-1 text-center text-balance text-gray-500"> - Create a copy of this course + <div className="relative flex flex-col items-center p-8"> + <div className="p-4"> + <Copy className="size-12 text-gray-300" strokeWidth={1.5} /> + </div> + + <div className="mt-6 text-center"> + <h2 className="text-2xl font-bold text-gray-900">Fork Course</h2> + <p className="mt-3 text-center leading-relaxed text-balance text-gray-600"> + Create a copy of this course to track your progress and make changes + to suit your learning style. </p> </div> - <div className="mt-4 grid w-full grid-cols-2 gap-2"> + <div className="mt-8 grid w-full grid-cols-2 gap-3"> <button disabled={isPending} - className="flex items-center justify-center gap-2 rounded-md bg-gray-100 p-2 hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50" + onClick={onClose} + className="flex items-center justify-center gap-2 rounded-lg border border-gray-200 px-4 py-2.5 font-medium text-gray-700 transition-all hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50" > Cancel </button> <button disabled={isPending} - className="flex h-10 items-center justify-center gap-2 rounded-md bg-black p-2 text-white hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" - onClick={() => { - forkCourse(); - }} + className="flex hover:opacity-80 items-center justify-center gap-2 rounded-lg bg-black px-4 py-2.5 font-medium text-white transition-all hover:bg-gray-900 disabled:cursor-not-allowed disabled:opacity-50" + onClick={() => forkCourse()} > {isPending ? ( - <Loader2Icon className="size-4 animate-spin" /> + <> + <Loader2Icon className="size-4 animate-spin" /> + <span>Forking...</span> + </> ) : ( - 'Fork Course' + <> + <GitForkIcon className="size-4" /> + <span>Fork Course</span> + </> )} </button> </div> From 4df9eebb34e9bba343d09f2008f3fb9051c1663f Mon Sep 17 00:00:00 2001 From: Kamran Ahmed <kamranahmed.se@gmail.com> Date: Fri, 11 Apr 2025 20:20:08 +0100 Subject: [PATCH 29/31] Add upgrade indicator in sidebar --- src/components/AITutor/AITutorSidebar.tsx | 41 ++++++++++++++++++- .../AITutor/AITutorSidebarProps.tsx | 13 ++++++ .../Billing/UpgradeAccountModal.tsx | 9 +++- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 src/components/AITutor/AITutorSidebarProps.tsx diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index 5ee4fccc2..471d75849 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -1,5 +1,9 @@ -import { BookOpen, Compass, Plus, Star, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { BookOpen, Compass, Plus, Star, X, Zap } from 'lucide-react'; import { AITutorLogo } from '../ReactIcons/AITutorLogo'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { useIsPaidUser } from '../../queries/billing'; +import { isLoggedIn } from '../../lib/jwt'; type AITutorSidebarProps = { isFloating: boolean; @@ -39,8 +43,21 @@ export type AITutorTab = (typeof sidebarItems)[number]['key']; export function AITutorSidebar(props: AITutorSidebarProps) { const { activeTab, isFloating, onClose } = props; + const [isInitialLoad, setIsInitialLoad] = useState(true); + + const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); + const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser(); + + useEffect(() => { + setIsInitialLoad(false); + }, []); + return ( <> + {isUpgradeModalOpen && ( + <UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} /> + )} + <aside className={`w-[255px] shrink-0 border-r border-slate-200 ${ isFloating @@ -94,6 +111,28 @@ export function AITutorSidebar(props: AITutorSidebarProps) { </a> </li> ))} + + {!isInitialLoad && + isLoggedIn() && + !isPaidUser && + !isPaidUserLoading && ( + <li> + <button + onClick={() => { + setIsUpgradeModalOpen(true); + }} + className="mx-4 mt-4 rounded-xl bg-amber-100 p-4 text-left transition-colors hover:bg-amber-200/80" + > + <span className="mb-2 flex items-center gap-2"> + <Zap className="size-4 text-amber-600" /> + <span className="font-medium text-amber-900">Upgrade</span> + </span> + <span className="mt-1 block text-left text-xs leading-4 text-amber-700"> + Get access to all features and benefits of the AI Tutor. + </span> + </button> + </li> + )} </ul> </aside> {isFloating && ( diff --git a/src/components/AITutor/AITutorSidebarProps.tsx b/src/components/AITutor/AITutorSidebarProps.tsx new file mode 100644 index 000000000..8ede3e1a4 --- /dev/null +++ b/src/components/AITutor/AITutorSidebarProps.tsx @@ -0,0 +1,13 @@ +import { Zap } from 'lucide-react'; + +<li> + <div className="mx-4 mt-4 rounded-lg bg-amber-50 p-3"> + <div className="flex items-center gap-2"> + <Zap className="size-4 text-amber-600" /> + <span className="font-medium text-amber-900">Free Tier</span> + </div> + <p className="mt-1 text-xs text-amber-700"> + Upgrade to Pro to unlock unlimited AI tutoring sessions + </p> + </div> +</li> \ No newline at end of file diff --git a/src/components/Billing/UpgradeAccountModal.tsx b/src/components/Billing/UpgradeAccountModal.tsx index 452fd2665..359c4acab 100644 --- a/src/components/Billing/UpgradeAccountModal.tsx +++ b/src/components/Billing/UpgradeAccountModal.tsx @@ -234,7 +234,14 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) { )} </p> )} - <p className="text-2xl font-bold text-black sm:text-3xl"> + <p + className={cn( + 'text-2xl font-bold text-black sm:text-3xl', + { + 'mt-0 md:mt-6': !isYearly, + }, + )} + > ${plan.amount}{' '} <span className="text-xs font-normal text-gray-500 sm:text-sm"> / {isYearly ? 'year' : 'month'} From 74c20a66fca0bcfc3f8b3d377188e0985fff79a8 Mon Sep 17 00:00:00 2001 From: Arik Chakma <arikchangma@gmail.com> Date: Sat, 12 Apr 2025 01:26:17 +0600 Subject: [PATCH 30/31] fix: ai course access --- .../GenerateCourse/AICourseContent.tsx | 4 +++ .../GenerateCourse/AICourseLesson.tsx | 20 +++++++++-- .../GenerateCourse/AICourseLessonChat.tsx | 22 ++++++++++-- .../GenerateCourse/AICourseLimit.tsx | 5 +++ .../GenerateCourse/AICourseOutlineHeader.tsx | 5 --- .../GenerateCourse/AICourseRoadmapView.tsx | 36 +++++++++++++++++-- .../GenerateCourse/GenerateAICourse.tsx | 3 ++ src/components/GenerateCourse/GetAICourse.tsx | 8 +---- .../GenerateCourse/RegenerateLesson.tsx | 19 +++++++++- 9 files changed, 101 insertions(+), 21 deletions(-) diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index 462404049..ba484cd76 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -513,6 +513,10 @@ export function AICourseContent(props: AICourseContentProps) { setExpandedModules={setExpandedModules} onUpgradeClick={() => setShowUpgradeModal(true)} viewMode={viewMode} + isForkable={isForkable} + onForkCourse={() => { + setIsForkingCourse(true); + }} /> )} diff --git a/src/components/GenerateCourse/AICourseLesson.tsx b/src/components/GenerateCourse/AICourseLesson.tsx index dd4a443a2..9ef6e0a49 100644 --- a/src/components/GenerateCourse/AICourseLesson.tsx +++ b/src/components/GenerateCourse/AICourseLesson.tsx @@ -40,6 +40,7 @@ import { ResizablePanel, ResizablePanelGroup, } from './Resizeable'; +import { showLoginPopup } from '../../lib/popup'; function getQuestionsFromResult(result: string) { const matchedQuestions = result.match( @@ -301,13 +302,13 @@ export function AICourseLesson(props: AICourseLessonProps) { </div> )} - <div className="mb-4 flex max-sm:flex-col-reverse justify-between"> + <div className="mb-4 flex justify-between max-sm:flex-col-reverse"> <div className="text-sm text-gray-500"> Lesson {activeLessonIndex + 1} of {totalLessons} </div> {!isGenerating && !isLoading && ( - <div className="md:absolute top-2 right-2 flex items-center max-sm:justify-end gap-2 lg:top-6 lg:right-6 mb-3"> + <div className="top-2 right-2 mb-3 flex items-center gap-2 max-sm:justify-end md:absolute lg:top-6 lg:right-6"> <button onClick={() => setIsAIChatsOpen(!isAIChatsOpen)} className="rounded-full p-1 text-gray-400 hover:text-black max-lg:hidden" @@ -345,6 +346,11 @@ export function AICourseLesson(props: AICourseLessonProps) { : 'bg-green-500 hover:bg-green-600', )} onClick={() => { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + if (isForkable) { onForkCourse(); return; @@ -429,10 +435,18 @@ export function AICourseLesson(props: AICourseLessonProps) { {!isLoggedIn() && ( <div className="mt-8 flex min-h-[152px] flex-col items-center justify-center gap-3 rounded-lg border border-gray-200 p-8"> - <LockIcon className="size-7 stroke-2 text-gray-400/90" /> + <LockIcon className="size-10 stroke-2 text-gray-400/90" /> <p className="text-sm text-gray-500"> Please login to generate course content </p> + <button + onClick={() => { + showLoginPopup(); + }} + className="rounded-full bg-black px-4 py-1 text-sm text-white hover:bg-gray-800" + > + Login to Continue + </button> </div> )} diff --git a/src/components/GenerateCourse/AICourseLessonChat.tsx b/src/components/GenerateCourse/AICourseLessonChat.tsx index 4e6fc081e..442fb553a 100644 --- a/src/components/GenerateCourse/AICourseLessonChat.tsx +++ b/src/components/GenerateCourse/AICourseLessonChat.tsx @@ -34,6 +34,7 @@ import { queryClient } from '../../stores/query-client'; import { billingDetailsOptions } from '../../queries/billing'; import { ResizablePanel } from './Resizeable'; import { Spinner } from '../ReactIcons/Spinner'; +import { showLoginPopup } from '../../lib/popup'; export type AllowedAIChatRole = 'user' | 'assistant'; export type AIChatHistoryType = { @@ -324,8 +325,8 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) { className="relative flex items-start border-t border-gray-200 text-sm" onSubmit={handleChatSubmit} > - {isLimitExceeded && ( - <div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white"> + {isLimitExceeded && isLoggedIn() && ( + <div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white"> <LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} @@ -346,6 +347,23 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) { )} </div> )} + {!isLoggedIn() && ( + <div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white"> + <LockIcon + className="size-4 cursor-not-allowed" + strokeWidth={2.5} + /> + <p className="cursor-not-allowed">Please login to continue</p> + <button + onClick={() => { + showLoginPopup(); + }} + className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300" + > + Login to Chat + </button> + </div> + )} <TextareaAutosize className={cn( 'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-hidden', diff --git a/src/components/GenerateCourse/AICourseLimit.tsx b/src/components/GenerateCourse/AICourseLimit.tsx index f2c80a80b..d41a4d699 100644 --- a/src/components/GenerateCourse/AICourseLimit.tsx +++ b/src/components/GenerateCourse/AICourseLimit.tsx @@ -4,6 +4,7 @@ import { getPercentage } from '../../lib/number'; import { getAiCourseLimitOptions } from '../../queries/ai-course'; import { billingDetailsOptions } from '../../queries/billing'; import { queryClient } from '../../stores/query-client'; +import { isLoggedIn } from '../../lib/jwt'; type AICourseLimitProps = { onUpgrade: () => void; @@ -21,6 +22,10 @@ export function AICourseLimit(props: AICourseLimitProps) { const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = useQuery(billingDetailsOptions(), queryClient); + if (!isLoggedIn()) { + return null; + } + if (isLoading || !limits || isBillingDetailsLoading || !userBillingDetails) { return ( <div className="hidden h-[38px] w-[208.09px] animate-pulse rounded-lg border border-gray-200 bg-gray-200 lg:block"></div> diff --git a/src/components/GenerateCourse/AICourseOutlineHeader.tsx b/src/components/GenerateCourse/AICourseOutlineHeader.tsx index 386efc7cb..d099bfa83 100644 --- a/src/components/GenerateCourse/AICourseOutlineHeader.tsx +++ b/src/components/GenerateCourse/AICourseOutlineHeader.tsx @@ -69,11 +69,6 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) { </button> <button onClick={() => { - if (isForkable) { - onForkCourse(); - return; - } - setViewMode('roadmap'); }} className={cn( diff --git a/src/components/GenerateCourse/AICourseRoadmapView.tsx b/src/components/GenerateCourse/AICourseRoadmapView.tsx index abfbfb6dd..05437a99a 100644 --- a/src/components/GenerateCourse/AICourseRoadmapView.tsx +++ b/src/components/GenerateCourse/AICourseRoadmapView.tsx @@ -17,13 +17,15 @@ import { } from 'react'; import type { AICourseViewMode } from './AICourseContent'; import { replaceChildren } from '../../lib/dom'; -import { Frown, Loader2Icon } from 'lucide-react'; +import { Frown, Loader2Icon, LockIcon } from 'lucide-react'; import { renderTopicProgress } from '../../lib/resource-progress'; import { queryClient } from '../../stores/query-client'; import { useQuery } from '@tanstack/react-query'; import { billingDetailsOptions } from '../../queries/billing'; import { AICourseOutlineHeader } from './AICourseOutlineHeader'; import type { AiCourse } from '../../lib/ai'; +import { showLoginPopup } from '../../lib/popup'; +import { isLoggedIn } from '../../lib/jwt'; export type AICourseRoadmapViewProps = { done: string[]; @@ -37,6 +39,8 @@ export type AICourseRoadmapViewProps = { onUpgradeClick: () => void; setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>; viewMode: AICourseViewMode; + isForkable: boolean; + onForkCourse: () => void; }; export function AICourseRoadmapView(props: AICourseRoadmapViewProps) { @@ -52,6 +56,8 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) { setExpandedModules, onUpgradeClick, viewMode, + isForkable, + onForkCourse, } = props; const containerEl = useRef<HTMLDivElement>(null); @@ -66,6 +72,11 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) { const isPaidUser = userBillingDetails?.status === 'active'; const generateAICourseRoadmap = async (courseSlug: string) => { + if (!isLoggedIn()) { + setIsGenerating(false); + return; + } + try { const response = await fetch( `${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course-roadmap/${courseSlug}`, @@ -216,6 +227,8 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) { }} viewMode={viewMode} setViewMode={setViewMode} + isForkable={isForkable} + onForkCourse={onForkCourse} /> {isLoading && ( <div className="absolute inset-0 flex h-full w-full items-center justify-center"> @@ -223,10 +236,27 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) { </div> )} - {error && !isGenerating && ( + {!isLoggedIn() && ( + <div className="absolute inset-0 flex h-full w-full flex-col items-center justify-center gap-2"> + <LockIcon className="size-10 stroke-2 text-gray-400/90" /> + <p className="text-sm text-gray-500"> + Please login to generate course content + </p> + <button + onClick={() => { + showLoginPopup(); + }} + className="rounded-full bg-black px-4 py-1 text-sm text-white hover:bg-gray-800" + > + Login to Continue + </button> + </div> + )} + + {error && !isGenerating && !isLoggedIn() && ( <div className="absolute inset-0 flex h-full w-full flex-col items-center justify-center"> <Frown className="size-20 text-red-500" /> - <p className="mx-auto mt-5 max-w-[250px] text-balance text-center text-base text-red-500"> + <p className="mx-auto mt-5 max-w-[250px] text-center text-base text-balance text-red-500"> {error || 'Something went wrong'} </p> diff --git a/src/components/GenerateCourse/GenerateAICourse.tsx b/src/components/GenerateCourse/GenerateAICourse.tsx index 8b901ad94..092cc0964 100644 --- a/src/components/GenerateCourse/GenerateAICourse.tsx +++ b/src/components/GenerateCourse/GenerateAICourse.tsx @@ -7,6 +7,7 @@ import { generateCourse } from '../../helper/generate-ai-course'; import { useQuery } from '@tanstack/react-query'; import { getAiCourseOptions } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; +import { useAuth } from '../../hooks/use-auth'; type GenerateAICourseProps = {}; @@ -20,6 +21,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); + const currentUser = useAuth(); const [courseId, setCourseId] = useState(''); const [courseSlug, setCourseSlug] = useState(''); @@ -150,6 +152,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) { return ( <AICourseContent courseSlug={courseSlug} + creatorId={currentUser?.id} course={course} isLoading={isLoading} error={error} diff --git a/src/components/GenerateCourse/GetAICourse.tsx b/src/components/GenerateCourse/GetAICourse.tsx index dfe45de9f..072b80689 100644 --- a/src/components/GenerateCourse/GetAICourse.tsx +++ b/src/components/GenerateCourse/GetAICourse.tsx @@ -20,17 +20,11 @@ export function GetAICourse(props: GetAICourseProps) { const { data: aiCourse, error: queryError } = useQuery( { ...getAiCourseOptions({ aiCourseSlug: courseSlug }), - enabled: !!courseSlug && !!isLoggedIn(), + enabled: !!courseSlug, }, queryClient, ); - useEffect(() => { - if (!isLoggedIn()) { - window.location.href = '/ai'; - } - }, [isLoggedIn]); - useEffect(() => { if (!aiCourse) { return; diff --git a/src/components/GenerateCourse/RegenerateLesson.tsx b/src/components/GenerateCourse/RegenerateLesson.tsx index 507d106b9..5bd468f51 100644 --- a/src/components/GenerateCourse/RegenerateLesson.tsx +++ b/src/components/GenerateCourse/RegenerateLesson.tsx @@ -4,6 +4,8 @@ import { useOutsideClick } from '../../hooks/use-outside-click'; import { cn } from '../../lib/classname'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { ModifyCoursePrompt } from './ModifyCoursePrompt'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; type RegenerateLessonProps = { onRegenerateLesson: (prompt?: string) => void; @@ -39,6 +41,11 @@ export function RegenerateLesson(props: RegenerateLessonProps) { onClose={() => setShowPromptModal(false)} onSubmit={(prompt) => { setShowPromptModal(false); + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + if (isForkable) { onForkCourse(); return; @@ -49,7 +56,7 @@ export function RegenerateLesson(props: RegenerateLessonProps) { /> )} - <div className="relative lg:mr-1 flex items-center" ref={ref}> + <div className="relative flex items-center lg:mr-1" ref={ref}> <button className={cn('rounded-full p-1 text-gray-400 hover:text-black', { 'text-black': isDropdownVisible, @@ -63,6 +70,11 @@ export function RegenerateLesson(props: RegenerateLessonProps) { <button onClick={() => { setIsDropdownVisible(false); + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + if (isForkable) { onForkCourse(); return; @@ -82,6 +94,11 @@ export function RegenerateLesson(props: RegenerateLessonProps) { <button onClick={() => { setIsDropdownVisible(false); + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + if (isForkable) { onForkCourse(); return; From c28593142eca6fd832fca908ea3a23a1e9b74fe5 Mon Sep 17 00:00:00 2001 From: Arik Chakma <arikchangma@gmail.com> Date: Sat, 12 Apr 2025 01:32:57 +0600 Subject: [PATCH 31/31] fix: next lesson --- src/components/GenerateCourse/AICourseLesson.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/GenerateCourse/AICourseLesson.tsx b/src/components/GenerateCourse/AICourseLesson.tsx index 9ef6e0a49..28c053521 100644 --- a/src/components/GenerateCourse/AICourseLesson.tsx +++ b/src/components/GenerateCourse/AICourseLesson.tsx @@ -477,6 +477,11 @@ export function AICourseLesson(props: AICourseLessonProps) { <div> <button onClick={() => { + if (!isLoggedIn()) { + onGoToNextLesson(); + return; + } + if (!isLessonDone) { toggleDone(undefined, { onSuccess: () => {