feat/ai-tutor-redesign
Arik Chakma 5 days ago
parent 3a20912f0f
commit da14f05079
  1. 96
      src/components/AITutor/AIExploreCourseListing.tsx
  2. 98
      src/components/AITutor/AIFeaturedCoursesListing.tsx
  3. 17
      src/components/AITutor/AITutorLayout.tsx
  4. 20
      src/components/AITutor/AITutorSidebar.tsx
  5. 8
      src/components/GenerateCourse/AICourse.tsx
  6. 10
      src/components/GenerateCourse/AICourseCard.tsx
  7. 21
      src/pages/ai/courses.astro
  8. 21
      src/pages/ai/explore.astro
  9. 13
      src/pages/ai/index.astro
  10. 21
      src/pages/ai/stuff-picks.astro
  11. 48
      src/queries/ai-course.ts

@ -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>
)}
</>
);
}

@ -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>
)}
</>
);
}

@ -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>
);
}

@ -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;

@ -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>
);

@ -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>
)}

@ -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>

@ -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>

@ -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>

@ -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>

@ -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,
);
}

Loading…
Cancel
Save