diff --git a/src/components/GenerateCourse/AICourseModuleList.tsx b/src/components/GenerateCourse/AICourseModuleList.tsx index 3ed31bb01..6fa4f85c7 100644 --- a/src/components/GenerateCourse/AICourseModuleList.tsx +++ b/src/components/GenerateCourse/AICourseModuleList.tsx @@ -1,16 +1,13 @@ import { type Dispatch, type SetStateAction, useState } from 'react'; import type { AiCourse } from '../../lib/ai'; -import { - CheckCircleIcon, - ChevronDownIcon, - ChevronRightIcon, -} from 'lucide-react'; +import { Check, ChevronDownIcon, ChevronRightIcon } from 'lucide-react'; import { cn } from '../../lib/classname'; import { getAiCourseProgressOptions } from '../../queries/ai-course'; import { useQuery } from '@tanstack/react-query'; import { queryClient } from '../../stores/query-client'; import { slugify } from '../../lib/slugger'; import { CheckIcon } from '../ReactIcons/CheckIcon'; +import { CircularProgress } from './CircularProgress'; type AICourseModuleListProps = { course: AiCourse; @@ -43,7 +40,7 @@ export function AICourseModuleList(props: AICourseModuleListProps) { setExpandedModules, } = props; - const { data: aiCourseProgress } = useQuery( + const { data: aiCourseProgress, isLoading } = useQuery( getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }), queryClient, ); @@ -74,85 +71,115 @@ export function AICourseModuleList(props: AICourseModuleListProps) { return ( ); } diff --git a/src/components/GenerateCourse/AICourseModuleView.tsx b/src/components/GenerateCourse/AICourseModuleView.tsx index fb7730250..985d72bfe 100644 --- a/src/components/GenerateCourse/AICourseModuleView.tsx +++ b/src/components/GenerateCourse/AICourseModuleView.tsx @@ -1,6 +1,6 @@ import { ChevronLeft, ChevronRight, Loader2Icon, LockIcon } from 'lucide-react'; import { cn } from '../../lib/classname'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; import { readAICourseLessonStream } from '../../helper/read-stream'; import { markdownToHtml } from '../../lib/markdown'; @@ -52,6 +52,11 @@ export function AICourseModuleView(props: AICourseModuleViewProps) { const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`; const isLessonDone = aiCourseProgress?.done.includes(lessonId); + const abortController = useMemo( + () => new AbortController(), + [activeModuleIndex, activeLessonIndex], + ); + const generateAiCourseContent = async () => { setIsLoading(true); setError(''); @@ -76,6 +81,7 @@ export function AICourseModuleView(props: AICourseModuleViewProps) { headers: { 'Content-Type': 'application/json', }, + signal: abortController.signal, credentials: 'include', body: JSON.stringify({ moduleTitle: currentModuleTitle, @@ -111,9 +117,17 @@ export function AICourseModuleView(props: AICourseModuleViewProps) { setIsGenerating(true); await readAICourseLessonStream(reader, { onStream: async (result) => { + if (abortController.signal.aborted) { + return; + } + setLessonHtml(markdownToHtml(result, false)); }, onStreamEnd: () => { + if (abortController.signal.aborted) { + return; + } + setIsGenerating(false); }, }); @@ -126,6 +140,13 @@ export function AICourseModuleView(props: AICourseModuleViewProps) { lessonId, }); }, + onSuccess: () => { + queryClient.invalidateQueries( + getAiCourseProgressOptions({ + aiCourseSlug: courseSlug || '', + }), + ); + }, }, queryClient, ); @@ -134,6 +155,12 @@ export function AICourseModuleView(props: AICourseModuleViewProps) { generateAiCourseContent(); }, [currentModuleTitle, currentLessonTitle]); + useEffect(() => { + return () => { + abortController.abort(); + }; + }, [abortController]); + return (
diff --git a/src/components/GenerateCourse/CircularProgress.tsx b/src/components/GenerateCourse/CircularProgress.tsx new file mode 100644 index 000000000..67b6386e9 --- /dev/null +++ b/src/components/GenerateCourse/CircularProgress.tsx @@ -0,0 +1,57 @@ +import { cn } from '../../lib/classname'; + +export function ChapterNumberSkeleton() { + return ( +
+ ); +} + +type CircularProgressProps = { + percentage: number; + children: React.ReactNode; + isVisible?: boolean; + isActive?: boolean; + isLoading?: boolean; +}; + +export function CircularProgress(props: CircularProgressProps) { + const { + percentage, + children, + isVisible = true, + isActive = false, + isLoading = false, + } = props; + + const circumference = 2 * Math.PI * 13; + const strokeDasharray = `${circumference}`; + const strokeDashoffset = circumference - (percentage / 100) * circumference; + + return ( +
+ {isVisible && !isLoading && ( + + + + )} + + {!isLoading && children} + {isLoading && } +
+ ); +}