diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index 82a58f612..73e692d72 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -2,7 +2,6 @@ import { ArrowLeft, BookOpenCheck, ChevronDown, - ChevronLeft, ChevronRight, Loader2, Menu, @@ -12,6 +11,7 @@ import { useEffect, useState } from 'react'; import { readAICourseStream } from '../../helper/read-stream'; import { cn } from '../../lib/classname'; import { getUrlParams } from '../../lib/browser'; +import { AICourseModuleView } from './AICourseModuleView'; type Lesson = string; @@ -112,7 +112,8 @@ export function AICourseContent(props: AICourseContentProps) { return; } - generateCourse({ term: paramsTerm, difficulty: paramsDifficulty }); + setTerm(paramsTerm); + setDifficulty(paramsDifficulty); }, [term, difficulty, courseSlug]); const generateCourse = async ({ @@ -341,7 +342,6 @@ export function AICourseContent(props: AICourseContentProps) { return ( <section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50"> - {/* Top navigation bar */} <header className="flex h-16 items-center justify-between bg-white px-4 shadow-sm"> <div className="flex items-center"> <button @@ -382,9 +382,7 @@ export function AICourseContent(props: AICourseContentProps) { </div> </header> - {/* Main content with sidebar */} <div className="flex flex-1 overflow-hidden"> - {/* Sidebar */} <aside className={cn( 'fixed inset-y-0 left-0 z-20 mt-16 w-80 transform overflow-y-auto border-r border-gray-200 bg-white pt-4 transition-transform duration-200 ease-in-out md:relative md:mt-0 md:translate-x-0', @@ -495,88 +493,27 @@ export function AICourseContent(props: AICourseContentProps) { </nav> </aside> - {/* Main content */} <main className={cn( 'flex-1 overflow-y-auto p-6 transition-all duration-200 ease-in-out', sidebarOpen ? 'md:ml-0' : '', )} > - {viewMode === 'module' ? ( - <div className="mx-auto max-w-4xl"> - {/* Module and lesson navigation */} - <div className="mb-6 flex flex-wrap items-center justify-between gap-4"> - <div> - <div className="text-sm text-gray-500"> - Module {activeModuleIndex + 1} of {totalModules} - </div> - <h2 className="text-2xl font-bold"> - {currentModule?.title?.replace( - /^Module\s*?\d+[\.:]\s*/, - '', - ) || 'Loading...'} - </h2> - </div> - </div> - - {/* Current lesson */} - <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm"> - <div className="mb-4 flex items-center justify-between"> - <div className="text-sm text-gray-500"> - Lesson {activeLessonIndex + 1} of {totalLessons} - </div> - </div> - - <h3 className="mb-6 text-xl font-semibold"> - {currentLesson?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} - </h3> - - <div className="prose max-w-none"> - <p className="text-gray-600"> - This lesson is part of the "{currentModule?.title}" module. - </p> - </div> + {viewMode === 'module' && ( + <AICourseModuleView + courseSlug={courseSlug} + activeModuleIndex={activeModuleIndex} + totalModules={totalModules} + currentModuleTitle={currentModule?.title || ''} + activeLessonIndex={activeLessonIndex} + totalLessons={totalLessons} + currentLessonTitle={currentLesson || ''} + onGoToPrevLesson={goToPrevLesson} + onGoToNextLesson={goToNextLesson} + /> + )} - {/* Navigation buttons */} - <div className="mt-8 flex items-center justify-between"> - <button - onClick={goToPrevLesson} - disabled={ - activeModuleIndex === 0 && activeLessonIndex === 0 - } - className={cn( - 'flex items-center rounded-md px-4 py-2', - activeModuleIndex === 0 && activeLessonIndex === 0 - ? 'cursor-not-allowed text-gray-400' - : 'bg-gray-100 text-gray-700 hover:bg-gray-200', - )} - > - <ChevronLeft size={16} className="mr-2" /> - Previous Lesson - </button> - - <button - onClick={goToNextLesson} - disabled={ - activeModuleIndex === totalModules - 1 && - activeLessonIndex === totalLessons - 1 - } - className={cn( - 'flex items-center rounded-md px-4 py-2', - activeModuleIndex === totalModules - 1 && - activeLessonIndex === totalLessons - 1 - ? 'cursor-not-allowed text-gray-400' - : 'bg-gray-800 text-white hover:bg-gray-700', - )} - > - Next Lesson - <ChevronRight size={16} className="ml-2" /> - </button> - </div> - </div> - </div> - ) : ( - /* Full course content view */ + {viewMode === 'full' && ( <div className="mx-auto max-w-3xl rounded-xl border border-gray-200 bg-white p-6 shadow-sm"> <div className="mb-4 flex items-center justify-between"> <h2 className="text-xl font-bold">Course Outline</h2> @@ -643,7 +580,6 @@ export function AICourseContent(props: AICourseContentProps) { </main> </div> - {/* Overlay for mobile sidebar */} {sidebarOpen && ( <div className="fixed inset-0 z-10 bg-gray-900 bg-opacity-50 md:hidden" diff --git a/src/components/GenerateCourse/AICourseModuleView.tsx b/src/components/GenerateCourse/AICourseModuleView.tsx new file mode 100644 index 000000000..d49e94cfa --- /dev/null +++ b/src/components/GenerateCourse/AICourseModuleView.tsx @@ -0,0 +1,204 @@ +import { ChevronLeft, ChevronRight, Loader2Icon, LockIcon } from 'lucide-react'; +import { cn } from '../../lib/classname'; +import { useEffect, useState } from 'react'; +import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; +import { readAICourseLessonStream } from '../../helper/read-stream'; +import { markdownToHtml } from '../../lib/markdown'; + +type AICourseModuleViewProps = { + courseSlug: string; + + activeModuleIndex: number; + totalModules: number; + currentModuleTitle: string; + activeLessonIndex: number; + totalLessons: number; + currentLessonTitle: string; + + onGoToPrevLesson: () => void; + onGoToNextLesson: () => void; +}; + +export function AICourseModuleView(props: AICourseModuleViewProps) { + const { + courseSlug, + + activeModuleIndex, + totalModules, + currentModuleTitle, + activeLessonIndex, + totalLessons, + currentLessonTitle, + + onGoToPrevLesson, + onGoToNextLesson, + } = props; + + const [isLoading, setIsLoading] = useState(true); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(''); + + const [lessonHtml, setLessonHtml] = useState(''); + + const generateAiCourseContent = async () => { + setIsLoading(true); + setError(''); + + if (!isLoggedIn()) { + setIsLoading(false); + setError('Please login to generate course content'); + return; + } + + if (!currentModuleTitle || !currentLessonTitle) { + setIsLoading(false); + setError('Invalid module title or lesson title'); + return; + } + + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course-lesson/${courseSlug}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + moduleTitle: currentModuleTitle, + lessonTitle: currentLessonTitle, + modulePosition: activeModuleIndex, + lessonPosition: activeLessonIndex, + totalLessonsInModule: totalLessons, + }), + }, + ); + + if (!response.ok) { + const data = await response.json(); + + setError(data?.message || 'Something went wrong'); + setIsLoading(false); + + // Logout user if token is invalid + if (data.status === 401) { + removeAuthToken(); + window.location.reload(); + } + } + const reader = response.body?.getReader(); + + if (!reader) { + setIsLoading(false); + setError('Something went wrong'); + return; + } + + setIsLoading(false); + setIsGenerating(true); + await readAICourseLessonStream(reader, { + onStream: async (result) => { + setLessonHtml(markdownToHtml(result, false)); + }, + onStreamEnd: () => { + setIsGenerating(false); + }, + }); + }; + + useEffect(() => { + generateAiCourseContent(); + }, [currentModuleTitle, currentLessonTitle]); + + return ( + <div className="mx-auto max-w-4xl"> + <div className="mb-6 flex flex-wrap items-center justify-between gap-4"> + <div> + <div className="text-sm text-gray-500"> + Module {activeModuleIndex + 1} of {totalModules} + </div> + <h2 className="text-2xl font-bold"> + {currentModuleTitle?.replace(/^Module\s*?\d+[\.:]\s*/, '') || + 'Loading...'} + </h2> + </div> + </div> + + <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm"> + <div className="mb-4 flex items-center justify-between"> + <div className="text-sm text-gray-500"> + Lesson {activeLessonIndex + 1} of {totalLessons} + </div> + </div> + + <div className="mb-6 flex items-center justify-between gap-2"> + <h3 className="text-xl font-semibold"> + {currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} + </h3> + + {(isGenerating || isLoading) && ( + <div className="flex items-center justify-center"> + <Loader2Icon size={24} className="animate-spin text-gray-400" /> + </div> + )} + </div> + + {!error && isLoggedIn() && ( + <div + className="prose max-w-none" + dangerouslySetInnerHTML={{ __html: lessonHtml }} + /> + )} + + {error && isLoggedIn() && ( + <div className="mt-8 flex items-center justify-center"> + <p className="text-red-500">{error}</p> + </div> + )} + + {!isLoggedIn() && ( + <div className="mt-8 flex flex-col items-center justify-center gap-4 rounded-lg border border-gray-200 p-8"> + <LockIcon className="size-10 stroke-[2.5] text-gray-400" /> + <p className="text-sm text-gray-500"> + Please login to generate course content + </p> + </div> + )} + + <div className="mt-8 flex items-center justify-between"> + <button + onClick={onGoToPrevLesson} + disabled={activeModuleIndex === 0 && activeLessonIndex === 0} + className={cn( + 'flex items-center rounded-md px-4 py-2', + activeModuleIndex === 0 && activeLessonIndex === 0 + ? 'cursor-not-allowed text-gray-400' + : 'bg-gray-100 text-gray-700 hover:bg-gray-200', + )} + > + <ChevronLeft size={16} className="mr-2" /> + Previous Lesson + </button> + + <button + onClick={onGoToNextLesson} + disabled={ + activeModuleIndex === totalModules - 1 && + activeLessonIndex === totalLessons - 1 + } + className={cn( + 'flex items-center rounded-md px-4 py-2', + activeModuleIndex === totalModules - 1 && + activeLessonIndex === totalLessons - 1 + ? 'cursor-not-allowed text-gray-400' + : 'bg-gray-800 text-white hover:bg-gray-700', + )} + > + Next Lesson + <ChevronRight size={16} className="ml-2" /> + </button> + </div> + </div> + </div> + ); +} diff --git a/src/helper/read-stream.ts b/src/helper/read-stream.ts index b2a347765..2d65de068 100644 --- a/src/helper/read-stream.ts +++ b/src/helper/read-stream.ts @@ -111,3 +111,31 @@ export async function readAICourseStream( onStreamEnd?.(result); reader.releaseLock(); } + +export async function readAICourseLessonStream( + reader: ReadableStreamDefaultReader<Uint8Array>, + { + onStream, + onStreamEnd, + }: { + onStream?: (lesson: string) => void; + onStreamEnd?: (lesson: string) => void; + }, +) { + const decoder = new TextDecoder('utf-8'); + let result = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + result += decoder.decode(value); + onStream?.(result); + } + + onStream?.(result); + onStreamEnd?.(result); + reader.releaseLock(); +}