import { useMutation } from '@tanstack/react-query'; import { CheckIcon, ChevronLeft, ChevronRight, Loader2Icon, LockIcon, XIcon, } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import type { AICourseDocument } from '../../api/ai-roadmap'; import { readStream } from '../../lib/ai'; import { cn } from '../../lib/classname'; import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; import { markdownToHtml, markdownToHtmlWithHighlighting, } from '../../lib/markdown'; import { httpPatch } from '../../lib/query-http'; import { slugify } from '../../lib/slugger'; import { getAiCourseLimitOptions, getAiCourseOptions, } from '../../queries/ai-course'; import { useIsPaidUser } from '../../queries/billing'; import { queryClient } from '../../stores/query-client'; import { AICourseFollowUp } from './AICourseFollowUp'; import './AICourseFollowUp.css'; import { RegenerateLesson } from './RegenerateLesson'; import { TestMyKnowledgeAction } from './TestMyKnowledgeAction'; type AICourseLessonProps = { courseSlug: string; progress: string[]; activeModuleIndex: number; totalModules: number; currentModuleTitle: string; activeLessonIndex: number; totalLessons: number; currentLessonTitle: string; onGoToPrevLesson: () => void; onGoToNextLesson: () => void; onUpgrade: () => void; }; export function AICourseLesson(props: AICourseLessonProps) { const { courseSlug, progress = [], activeModuleIndex, totalModules, currentModuleTitle, activeLessonIndex, totalLessons, currentLessonTitle, onGoToPrevLesson, onGoToNextLesson, onUpgrade, } = props; const [isLoading, setIsLoading] = useState(true); const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(''); const [lessonHtml, setLessonHtml] = useState(''); const lessonId = `${slugify(String(activeModuleIndex))}-${slugify(String(activeLessonIndex))}`; const isLessonDone = progress?.includes(lessonId); const { isPaidUser } = useIsPaidUser(); const abortController = useMemo( () => new AbortController(), [activeModuleIndex, activeLessonIndex], ); const generateAiCourseContent = async ( isForce?: boolean, customPrompt?: string, ) => { setIsLoading(true); setError(''); setLessonHtml(''); 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', }, signal: abortController.signal, credentials: 'include', body: JSON.stringify({ moduleIndex: activeModuleIndex, lessonIndex: activeLessonIndex, isForce, customPrompt, }), }, ); 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(); } return; } if (!response.body) { setIsLoading(false); setError('No response body received'); return; } try { const reader = response.body.getReader(); setIsLoading(false); setIsGenerating(true); await readStream(reader, { onStream: async (result) => { if (abortController.signal.aborted) { return; } setLessonHtml(markdownToHtml(result, false)); }, onStreamEnd: async (result) => { if (abortController.signal.aborted) { return; } setLessonHtml(await markdownToHtmlWithHighlighting(result)); queryClient.invalidateQueries(getAiCourseLimitOptions()); setIsGenerating(false); }, }); } catch (e) { setError(e instanceof Error ? e.message : 'Something went wrong'); setIsLoading(false); } }; const { mutate: toggleDone, isPending: isTogglingDone } = useMutation( { mutationFn: () => { return httpPatch<AICourseDocument>( `/v1-toggle-done-ai-lesson/${courseSlug}`, { moduleIndex: activeModuleIndex, lessonIndex: activeLessonIndex, }, ); }, onSuccess: (data) => { queryClient.setQueryData( getAiCourseOptions({ aiCourseSlug: courseSlug }).queryKey, data, ); }, }, queryClient, ); useEffect(() => { generateAiCourseContent(); }, [currentModuleTitle, currentLessonTitle]); useEffect(() => { return () => { abortController.abort(); }; }, [abortController]); const cantGoForward = (activeModuleIndex === totalModules - 1 && activeLessonIndex === totalLessons - 1) || isGenerating || isLoading; const cantGoBack = (activeModuleIndex === 0 && activeLessonIndex === 0) || isGenerating || isLoading; return ( <div className="mx-auto max-w-4xl"> <div className="relative rounded-lg border border-gray-200 bg-white p-6 shadow-sm max-lg:px-4 max-lg:pb-4 max-lg:pt-3"> {(isGenerating || isLoading) && ( <div className="absolute right-3 top-3 flex items-center justify-center"> <Loader2Icon size={18} strokeWidth={3} className="animate-spin text-gray-400/70" /> </div> )} <div className="mb-4 flex items-center justify-between"> <div className="text-sm text-gray-500"> Lesson {activeLessonIndex + 1} of {totalLessons} </div> {!isGenerating && !isLoading && ( <div className="absolute right-3 top-3 flex items-center justify-between gap-2"> <RegenerateLesson onRegenerateLesson={(prompt) => { generateAiCourseContent(true, prompt); }} /> <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', isLessonDone ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600', )} onClick={() => toggleDone()} > {isTogglingDone ? ( <> <Loader2Icon size={16} strokeWidth={3} className="animate-spin text-white" /> Please wait ... </> ) : ( <> {isLessonDone ? ( <> <XIcon size={16} /> Mark as Undone </> ) : ( <> <CheckIcon size={16} /> Mark as Done </> )} </> )} </button> </div> )} </div> <h1 className="mb-6 text-balance text-3xl font-semibold 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" dangerouslySetInnerHTML={{ __html: lessonHtml }} /> )} {error && isLoggedIn() && ( <div className="mt-8 flex min-h-[300px] items-center justify-center rounded-xl bg-red-50/80"> {error.includes('reached the limit') ? ( <div className="flex max-w-sm flex-col items-center text-center"> <h2 className="text-xl font-semibold text-red-600"> Limit reached </h2> <p className="my-3 text-red-600"> You have reached the AI usage limit for today. {!isPaidUser && <>Please upgrade your account to continue.</>} {isPaidUser && <> Please wait until tomorrow to continue.</>} </p> {!isPaidUser && ( <button onClick={() => { onUpgrade(); }} className="rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700" > Upgrade Account </button> )} </div> ) : ( <p className="text-red-600">{error}</p> )} </div> )} {!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" /> <p className="text-sm text-gray-500"> Please login to generate course content </p> </div> )} {!isLoading && !isGenerating && !error && ( <TestMyKnowledgeAction courseSlug={courseSlug} activeModuleIndex={activeModuleIndex} activeLessonIndex={activeLessonIndex} /> )} <div className="mt-8 flex items-center justify-between"> <button onClick={onGoToPrevLesson} disabled={cantGoBack} className={cn( 'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm', cantGoBack ? 'cursor-not-allowed text-gray-400' : 'bg-gray-100 text-gray-700 hover:bg-gray-200', )} > <ChevronLeft size={16} className="mr-2" /> Previous <span className="hidden lg:inline"> Lesson</span> </button> <div> <button onClick={() => { if (!isLessonDone) { toggleDone(undefined, { onSuccess: () => { onGoToNextLesson(); }, }); } else { onGoToNextLesson(); } }} disabled={cantGoForward || isTogglingDone} className={cn( 'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm', cantGoForward ? 'cursor-not-allowed text-gray-400' : 'bg-gray-800 text-white hover:bg-gray-700', )} > {isTogglingDone ? ( <> <Loader2Icon size={16} strokeWidth={3} className="animate-spin text-white" /> Please wait ... </> ) : ( <> Next <span className="hidden lg:inline"> Lesson</span> <ChevronRight size={16} className="ml-2" /> </> )} </button> </div> </div> </div> {!isGenerating && !isLoading && ( <AICourseFollowUp courseSlug={courseSlug} moduleTitle={currentModuleTitle} lessonTitle={currentLessonTitle} /> )} </div> ); }