diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index 0f9ee01d6..83e7b4668 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -1,36 +1,7 @@ -import { - ChevronDown, - ChevronRight, - Loader2, - Search, - Wand, - BookOpen, - ArrowLeft, - Menu, - ChevronLeft, - ChevronUp, - X, - BookOpenCheck, - Layers, - List, -} from 'lucide-react'; -import { useState, useEffect } from 'react'; -import { readAICourseStream } from '../../helper/read-stream'; - -// Define types for our course structure -type Lesson = string; - -type Module = { - title: string; - lessons: Lesson[]; -}; - -type Course = { - title: string; - modules: Module[]; - difficulty: string; -}; +import { Loader2, Search, Wand } from 'lucide-react'; +import { useState } from 'react'; +// Define types for difficulty levels type Difficulty = 'beginner' | 'intermediate' | 'advanced'; type AICourseProps = { @@ -38,363 +9,9 @@ type AICourseProps = { }; export function AICourse(props: AICourseProps) { - const { courseId: courseIdFromProps } = props; - - const [courseId, setCourseId] = useState(courseIdFromProps); const [keyword, setKeyword] = useState(''); const [difficulty, setDifficulty] = useState<Difficulty>('intermediate'); const [isLoading, setIsLoading] = useState(false); - const [courseContent, setCourseContent] = useState(''); - const [streamedCourse, setStreamedCourse] = useState<{ - title: string; - modules: Module[]; - }>({ - title: '', - modules: [], - }); - const [expandedModules, setExpandedModules] = useState< - Record<number, boolean> - >({}); - const [isStreamingMode, setIsStreamingMode] = useState(false); - const [activeModuleIndex, setActiveModuleIndex] = useState(0); - const [activeLessonIndex, setActiveLessonIndex] = useState(0); - const [sidebarOpen, setSidebarOpen] = useState(true); - const [viewMode, setViewMode] = useState<'module' | 'full'>('module'); - - const toggleModule = (index: number) => { - setExpandedModules((prev) => ({ - ...prev, - [index]: !prev[index], - })); - }; - - useEffect(() => { - if (courseIdFromProps) { - fetchCourse(); - } - }, [courseIdFromProps]); - - const fetchCourse = async () => { - if (!courseId) { - return; - } - - setIsLoading(true); - setStreamedCourse({ title: '', modules: [] }); - setExpandedModules({}); - setIsStreamingMode(true); - - try { - const response = await fetch( - `${import.meta.env.PUBLIC_API_URL || ''}/v1-get-ai-course/${courseId}`, - { - method: 'GET', - credentials: 'include', - }, - ); - - if (!response.ok) { - const data = await response.json(); - console.error( - 'Error fetching course:', - data?.message || 'Something went wrong', - ); - setIsLoading(false); - return; - } - - const reader = response.body?.getReader(); - - if (!reader) { - console.error('Failed to get reader from response'); - setIsLoading(false); - return; - } - - // Define regex patterns to extract course ID - const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); - - await readAICourseStream(reader, { - onStream: (result) => { - // Check if the result contains a course ID - if (result.includes('@COURSEID')) { - const courseIdMatch = result.match(COURSE_ID_REGEX); - const extractedCourseId = courseIdMatch?.[1] || ''; - - if (extractedCourseId) { - setCourseId(extractedCourseId); - - // Remove the course ID token from the result - result = result.replace(COURSE_ID_REGEX, ''); - } - } - - // Store the raw content and log it - setCourseContent(result); - - // Parse the streamed content to update the sidebar in real-time - try { - const lines = result.split('\n'); - let title = ''; - const modules: Module[] = []; - let currentModule: Module | null = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - if (i === 0 && line.startsWith('#')) { - // First line is the title - title = line.replace('#', '').trim(); - } else if (line.startsWith('## ')) { - // New module - if (currentModule) { - modules.push(currentModule); - } - currentModule = { - title: line.replace('## ', ''), - lessons: [], - }; - // Auto-expand the newest module - setExpandedModules((prev) => ({ - ...prev, - [modules.length]: true, - })); - } else if (line.startsWith('- ') && currentModule) { - // Lesson within current module - currentModule.lessons.push(line.replace('- ', '')); - } - } - - // Add the last module if it exists - if (currentModule) { - modules.push(currentModule); - } - - setStreamedCourse({ - title, - modules, - }); - } catch (e) { - console.error('Error parsing streamed course content:', e); - } - }, - onStreamEnd: (result) => { - // Clean up any tokens from the final result - result = result.replace(COURSE_ID_REGEX, ''); - setCourseContent(result); - - try { - const lines = result.split('\n'); - const title = lines[0].replace('#', '').trim(); - const modules: Module[] = []; - - let currentModule: Module | null = null; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - - if (line.startsWith('## ')) { - // New module - if (currentModule) { - modules.push(currentModule); - } - currentModule = { - title: line.replace('## ', ''), - lessons: [], - }; - } else if (line.startsWith('- ') && currentModule) { - // Lesson within current module - currentModule.lessons.push(line.replace('- ', '')); - } - } - - // Add the last module if it exists - if (currentModule) { - modules.push(currentModule); - } - - setStreamedCourse({ - title, - modules, - }); - } catch (e) { - console.error('Error parsing course content:', e); - } - - setIsLoading(false); - }, - }); - } catch (error) { - console.error('Error in course fetching:', error); - setIsLoading(false); - } - }; - - const generateCourse = async () => { - setIsLoading(true); - setStreamedCourse({ title: '', modules: [] }); - setExpandedModules({}); - setIsStreamingMode(true); - - try { - const response = await fetch( - `${import.meta.env.PUBLIC_API_URL || ''}/v1-generate-ai-course`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ - keyword, - difficulty, - }), - }, - ); - - if (!response.ok) { - const data = await response.json(); - console.error( - 'Error generating course:', - data?.message || 'Something went wrong', - ); - setIsLoading(false); - setIsStreamingMode(false); - return; - } - - const reader = response.body?.getReader(); - - if (!reader) { - console.error('Failed to get reader from response'); - setIsLoading(false); - setIsStreamingMode(false); - return; - } - - // Define regex patterns to extract course ID - const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); - - await readAICourseStream(reader, { - onStream: (result) => { - // Check if the result contains a course ID - if (result.includes('@COURSEID')) { - const courseIdMatch = result.match(COURSE_ID_REGEX); - const extractedCourseId = courseIdMatch?.[1] || ''; - - if (extractedCourseId) { - setCourseId(extractedCourseId); - - // Remove the course ID token from the result - result = result.replace(COURSE_ID_REGEX, ''); - } - } - - // Store the raw content and log it - setCourseContent(result); - - // Parse the streamed content to update the sidebar in real-time - try { - const lines = result.split('\n'); - let title = ''; - const modules: Module[] = []; - let currentModule: Module | null = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - if (i === 0 && line.startsWith('#')) { - // First line is the title - title = line.replace('#', '').trim(); - } else if (line.startsWith('## ')) { - // New module - if (currentModule) { - modules.push(currentModule); - } - currentModule = { - title: line.replace('## ', ''), - lessons: [], - }; - // Auto-expand the newest module - setExpandedModules((prev) => ({ - ...prev, - [modules.length]: true, - })); - } else if (line.startsWith('- ') && currentModule) { - // Lesson within current module - currentModule.lessons.push(line.replace('- ', '')); - } - } - - // Add the last module if it exists - if (currentModule) { - modules.push(currentModule); - } - - setStreamedCourse({ - title, - modules, - }); - } catch (e) { - console.error('Error parsing streamed course content:', e); - } - }, - onStreamEnd: (result) => { - // Clean up any tokens from the final result - result = result.replace(COURSE_ID_REGEX, ''); - setCourseContent(result); - - try { - const lines = result.split('\n'); - const title = lines[0].replace('#', '').trim(); - const modules: Module[] = []; - - let currentModule: Module | null = null; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - - if (line.startsWith('## ')) { - // New module - if (currentModule) { - modules.push(currentModule); - } - currentModule = { - title: line.replace('## ', ''), - lessons: [], - }; - } else if (line.startsWith('- ') && currentModule) { - // Lesson within current module - currentModule.lessons.push(line.replace('- ', '')); - } - } - - // Add the last module if it exists - if (currentModule) { - modules.push(currentModule); - } - - setStreamedCourse({ - title, - modules, - }); - } catch (e) { - console.error('Error parsing course content:', e); - } - - setIsLoading(false); - }, - }); - } catch (error) { - console.error('Error in course generation:', error); - setIsLoading(false); - setIsStreamingMode(false); - } - }; - - function onSubmit() { - generateCourse(); - } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && keyword.trim() && !isLoading) { @@ -402,300 +19,13 @@ export function AICourse(props: AICourseProps) { } }; - // Reset streaming mode - const handleReset = () => { - setIsStreamingMode(false); - setCourseContent(''); - setStreamedCourse({ title: '', modules: [] }); - }; - - // Navigation helpers - const goToNextModule = () => { - if (activeModuleIndex < streamedCourse.modules.length - 1) { - setActiveModuleIndex(activeModuleIndex + 1); - setActiveLessonIndex(0); - } - }; - - const goToPrevModule = () => { - if (activeModuleIndex > 0) { - setActiveModuleIndex(activeModuleIndex - 1); - setActiveLessonIndex(0); - } - }; - - const goToNextLesson = () => { - const currentModule = streamedCourse.modules[activeModuleIndex]; - if (currentModule && activeLessonIndex < currentModule.lessons.length - 1) { - setActiveLessonIndex(activeLessonIndex + 1); - } else { - goToNextModule(); - } - }; - - const goToPrevLesson = () => { - if (activeLessonIndex > 0) { - setActiveLessonIndex(activeLessonIndex - 1); - } else { - const prevModule = streamedCourse.modules[activeModuleIndex - 1]; - if (prevModule) { - setActiveModuleIndex(activeModuleIndex - 1); - setActiveLessonIndex(prevModule.lessons.length - 1); - } + function onSubmit() { + if (typeof window !== 'undefined') { + window.location.href = `/ai-tutor/search?term=${encodeURIComponent(keyword)}`; } - }; - - // Render the course UI when in streaming mode - if (isStreamingMode) { - const currentModule = streamedCourse.modules[activeModuleIndex]; - const currentLesson = currentModule?.lessons[activeLessonIndex]; - const totalModules = streamedCourse.modules.length; - const totalLessons = currentModule?.lessons.length || 0; - - 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 border-b border-gray-200 bg-white px-4 shadow-sm"> - <div className="flex items-center"> - <button - onClick={handleReset} - className="mr-4 rounded-md p-2 hover:bg-gray-100" - aria-label="Back to generator" - > - <ArrowLeft size={20} /> - </button> - <h1 className="text-xl font-bold"> - {streamedCourse.title || 'Loading Course...'} - </h1> - </div> - <div className="flex items-center gap-2"> - <button - onClick={() => - setViewMode(viewMode === 'module' ? 'full' : 'module') - } - className="flex items-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-gray-50" - > - {viewMode === 'module' ? ( - <> - <BookOpenCheck size={16} className="mr-2" /> - View Full Course - </> - ) : ( - <> - <Layers size={16} className="mr-2" /> - View Modules - </> - )} - </button> - <button - onClick={() => setSidebarOpen(!sidebarOpen)} - className="flex h-9 w-9 items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 md:hidden" - > - {sidebarOpen ? <X size={18} /> : <Menu size={18} />} - </button> - </div> - </header> - - {/* Main content with sidebar */} - <div className="flex flex-1 overflow-hidden"> - {/* Sidebar */} - <aside - className={`${ - sidebarOpen ? 'translate-x-0' : '-translate-x-full' - } 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`} - > - {/* Course title */} - <div className="mb-4 px-4"> - <div className="flex items-center justify-between"> - <h2 className="text-lg font-bold">Course Content</h2> - <button - onClick={() => setSidebarOpen(false)} - className="rounded-md p-1 hover:bg-gray-100 md:hidden" - > - <X size={18} /> - </button> - </div> - <div className="mt-2 text-sm text-gray-500"> - {totalModules} modules •{' '} - {streamedCourse.modules.reduce( - (total, module) => total + module.lessons.length, - 0, - )}{' '} - lessons - </div> - </div> - - {/* Module list */} - <nav className="space-y-1 px-2"> - {streamedCourse.modules.map((module, moduleIdx) => ( - <div key={moduleIdx} className="rounded-md"> - <button - onClick={() => toggleModule(moduleIdx)} - className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium ${ - activeModuleIndex === moduleIdx - ? 'bg-gray-100 text-gray-900' - : 'text-gray-700 hover:bg-gray-50' - }`} - > - <div className="flex min-w-0 items-start pr-2"> - <span className="mr-2 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold"> - {moduleIdx + 1} - </span> - <span className="break-words"> - {module.title?.replace(/^Module\s*?\d+[\.:]\s*/, '')} - </span> - </div> - {expandedModules[moduleIdx] ? ( - <ChevronDown size={16} className="flex-shrink-0" /> - ) : ( - <ChevronRight size={16} className="flex-shrink-0" /> - )} - </button> - - {/* Lessons */} - {expandedModules[moduleIdx] && ( - <div className="ml-8 mt-1 space-y-1"> - {module.lessons.map((lesson, lessonIdx) => ( - <button - key={lessonIdx} - onClick={() => { - setActiveModuleIndex(moduleIdx); - setActiveLessonIndex(lessonIdx); - }} - className={`flex w-full items-start rounded-md px-3 py-2 text-left text-sm ${ - activeModuleIndex === moduleIdx && - activeLessonIndex === lessonIdx - ? 'bg-gray-800 text-white' - : 'text-gray-600 hover:bg-gray-50' - }`} - > - <span className="relative top-[2px] mr-2 flex-shrink-0 text-xs"> - {lessonIdx + 1}. - </span> - <span className="break-words"> - {lesson?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} - </span> - </button> - ))} - </div> - )} - </div> - ))} - </nav> - </aside> - - {/* Main content */} - <main - className={`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> - - {/* Navigation buttons */} - <div className="mt-8 flex items-center justify-between"> - <button - onClick={goToPrevLesson} - disabled={ - activeModuleIndex === 0 && activeLessonIndex === 0 - } - className={`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={`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 */ - <div className="mx-auto max-w-3xl rounded-xl border border-gray-200 bg-white p-6 shadow-sm"> - <h2 className="mb-6 text-2xl font-bold">Full Course Content</h2> - {streamedCourse.title ? ( - <div className="prose max-w-none"> - <div - dangerouslySetInnerHTML={{ - __html: courseContent.replace(/\n/g, '<br />'), - }} - /> - </div> - ) : ( - <div className="flex h-64 items-center justify-center"> - <Loader2 size={40} className="animate-spin text-gray-400" /> - </div> - )} - </div> - )} - </main> - </div> - - {/* Overlay for mobile sidebar */} - {sidebarOpen && ( - <div - className="fixed inset-0 z-10 bg-gray-900 bg-opacity-50 md:hidden" - onClick={() => setSidebarOpen(false)} - ></div> - )} - </section> - ); } - // Render the original UI when not in streaming mode + // Render the search UI return ( <section className="flex flex-grow flex-col bg-gray-100"> <div className="container mx-auto flex max-w-3xl flex-col py-12"> diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx new file mode 100644 index 000000000..336c7d40f --- /dev/null +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -0,0 +1,647 @@ +import { + ArrowLeft, + BookOpenCheck, + ChevronDown, + ChevronLeft, + ChevronRight, + Layers, + Loader2, + Menu, + X, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { readAICourseStream } from '../../helper/read-stream'; +import { markdownToHtml } from '../../lib/markdown'; +import { getUrlParams } from '../../lib/browser'; + +// Define types for our course structure +type Lesson = string; + +type Module = { + title: string; + lessons: Lesson[]; +}; + +type Course = { + title: string; + modules: Module[]; + difficulty: string; +}; + +type AICourseContentProps = {}; + +export function AICourseContent(props: AICourseContentProps) { + const [term, setTerm] = useState(''); + const [difficulty, setDifficulty] = useState('beginner'); + + const [courseId, setCourseId] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [courseContent, setCourseContent] = useState(''); + + useEffect(() => { + const urlParams = getUrlParams(); + const termFromUrl = urlParams.term as string | ''; + const difficultyFromUrl = urlParams.difficulty || 'beginner'; + + if (!termFromUrl) { + window.location.href = '/ai-tutor'; + return; + } + + setTerm(termFromUrl); + setDifficulty(difficultyFromUrl); + }, []); + + const [streamedCourse, setStreamedCourse] = useState<{ + title: string; + modules: Module[]; + }>({ + title: '', + modules: [], + }); + const [expandedModules, setExpandedModules] = useState< + Record<number, boolean> + >({}); + const [activeModuleIndex, setActiveModuleIndex] = useState(0); + const [activeLessonIndex, setActiveLessonIndex] = useState(0); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [viewMode, setViewMode] = useState<'module' | 'full'>('full'); + + const toggleModule = (index: number) => { + setExpandedModules((prev) => { + // If this module is already expanded, collapse it + if (prev[index]) { + return { + ...prev, + [index]: false, + }; + } + + // Otherwise, collapse all modules and expand only this one + const newState: Record<number, boolean> = {}; + // Set all modules to collapsed + streamedCourse.modules.forEach((_, idx) => { + newState[idx] = false; + }); + // Expand only the clicked module + newState[index] = true; + return newState; + }); + }; + + useEffect(() => { + if (!term && !courseId) { + return; + } + + if (courseId) { + // fetchCourse(); + } else { + generateCourse(term, difficulty); + } + }, [courseId, term, difficulty]); + + const generateCourse = async (term: string, difficulty: string) => { + setIsLoading(true); + setStreamedCourse({ title: '', modules: [] }); + setExpandedModules({}); + setViewMode('full'); + + try { + const response = await fetch( + `${import.meta.env.PUBLIC_API_URL || ''}/v1-generate-ai-course`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + keyword: term, + difficulty, + }), + credentials: 'include', + }, + ); + + if (!response.ok) { + const data = await response.json(); + console.error( + 'Error generating course:', + data?.message || 'Something went wrong', + ); + setIsLoading(false); + return; + } + + const reader = response.body?.getReader(); + + if (!reader) { + console.error('Failed to get reader from response'); + setIsLoading(false); + return; + } + + // Define regex patterns to extract course ID + const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); + + await readAICourseStream(reader, { + onStream: (result) => { + // Check if the result contains a course ID + if (result.includes('@COURSEID')) { + const courseIdMatch = result.match(COURSE_ID_REGEX); + const extractedCourseId = courseIdMatch?.[1] || ''; + + if (extractedCourseId) { + console.log('extractedCourseId', extractedCourseId); + // setCourseId(extractedCourseId); + + // Remove the course ID token from the result + result = result.replace(COURSE_ID_REGEX, ''); + } + } + + // Store the raw content and log it + setCourseContent(result); + + // Parse the streamed content to update the sidebar in real-time + try { + const lines = result.split('\n'); + let title = ''; + const modules: Module[] = []; + let currentModule: Module | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (i === 0 && line.startsWith('#')) { + // First line is the title + title = line.replace('#', '').trim(); + } else if (line.startsWith('## ')) { + // New module + if (currentModule) { + modules.push(currentModule); + } + currentModule = { + title: line.replace('## ', ''), + lessons: [], + }; + // Removed auto-expand code to keep modules collapsed by default + } else if (line.startsWith('- ') && currentModule) { + // Lesson within current module + currentModule.lessons.push(line.replace('- ', '')); + } + } + + // Add the last module if it exists + if (currentModule) { + modules.push(currentModule); + } + + setStreamedCourse({ + title, + modules, + }); + } catch (e) { + console.error('Error parsing streamed course content:', e); + } + }, + onStreamEnd: (result) => { + // Clean up any tokens from the final result + result = result.replace(COURSE_ID_REGEX, ''); + setCourseContent(result); + + try { + const lines = result.split('\n'); + const title = lines[0].replace('#', '').trim(); + const modules: Module[] = []; + + let currentModule: Module | null = null; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + + if (line.startsWith('## ')) { + // New module + if (currentModule) { + modules.push(currentModule); + } + currentModule = { + title: line.replace('## ', ''), + lessons: [], + }; + } else if (line.startsWith('- ') && currentModule) { + // Lesson within current module + currentModule.lessons.push(line.replace('- ', '')); + } + } + + // Add the last module if it exists + if (currentModule) { + modules.push(currentModule); + } + + setStreamedCourse({ + title, + modules, + }); + } catch (e) { + console.error('Error parsing course content:', e); + } + + setIsLoading(false); + }, + }); + } catch (error) { + console.error('Error in course generation:', error); + setIsLoading(false); + } + }; + + // Navigation helpers + const goToNextModule = () => { + if (activeModuleIndex < streamedCourse.modules.length - 1) { + const nextModuleIndex = activeModuleIndex + 1; + setActiveModuleIndex(nextModuleIndex); + setActiveLessonIndex(0); + + // Expand the next module in the sidebar + setExpandedModules((prev) => { + const newState: Record<number, boolean> = {}; + // Set all modules to collapsed + streamedCourse.modules.forEach((_, idx) => { + newState[idx] = false; + }); + // Expand only the next module + newState[nextModuleIndex] = true; + return newState; + }); + } + }; + + const goToPrevModule = () => { + if (activeModuleIndex > 0) { + const prevModuleIndex = activeModuleIndex - 1; + setActiveModuleIndex(prevModuleIndex); + setActiveLessonIndex(0); + + // Expand the previous module in the sidebar + setExpandedModules((prev) => { + const newState: Record<number, boolean> = {}; + // Set all modules to collapsed + streamedCourse.modules.forEach((_, idx) => { + newState[idx] = false; + }); + // Expand only the previous module + newState[prevModuleIndex] = true; + return newState; + }); + } + }; + + const goToNextLesson = () => { + const currentModule = streamedCourse.modules[activeModuleIndex]; + if (currentModule && activeLessonIndex < currentModule.lessons.length - 1) { + setActiveLessonIndex(activeLessonIndex + 1); + } else { + goToNextModule(); + } + }; + + const goToPrevLesson = () => { + if (activeLessonIndex > 0) { + setActiveLessonIndex(activeLessonIndex - 1); + } else { + const prevModule = streamedCourse.modules[activeModuleIndex - 1]; + if (prevModule) { + const prevModuleIndex = activeModuleIndex - 1; + setActiveModuleIndex(prevModuleIndex); + setActiveLessonIndex(prevModule.lessons.length - 1); + + // Expand the previous module in the sidebar + setExpandedModules((prev) => { + const newState: Record<number, boolean> = {}; + // Set all modules to collapsed + streamedCourse.modules.forEach((_, idx) => { + newState[idx] = false; + }); + // Expand only the previous module + newState[prevModuleIndex] = true; + return newState; + }); + } + } + }; + + const currentModule = streamedCourse.modules[activeModuleIndex]; + const currentLesson = currentModule?.lessons[activeLessonIndex]; + const totalModules = streamedCourse.modules.length; + const totalLessons = currentModule?.lessons.length || 0; + + 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 + onClick={() => { + if (typeof window !== 'undefined') { + window.location.href = '/ai-tutor/search'; + } + }} + className="mr-4 rounded-md p-2 hover:bg-gray-100" + aria-label="Back to generator" + > + <ArrowLeft size={20} /> + </button> + <h1 className="text-xl font-bold"> + {streamedCourse.title || 'Loading Course...'} + </h1> + </div> + <div className="flex items-center gap-2"> + {viewMode === 'module' && ( + <button + onClick={() => { + // Collapse all modules in the sidebar when switching to outline view + setExpandedModules({}); + setViewMode('full'); + }} + className="flex items-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-gray-50" + > + <BookOpenCheck size={16} className="mr-2" /> + View Course Outline + </button> + )} + <button + onClick={() => setSidebarOpen(!sidebarOpen)} + className="flex h-9 w-9 items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 md:hidden" + > + {sidebarOpen ? <X size={18} /> : <Menu size={18} />} + </button> + </div> + </header> + + {/* Main content with sidebar */} + <div className="flex flex-1 overflow-hidden"> + {/* Sidebar */} + <aside + className={`${ + sidebarOpen ? 'translate-x-0' : '-translate-x-full' + } 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`} + > + {/* Course title */} + <div className="mb-4 px-4"> + <div className="flex items-center justify-between"> + <h2 className="text-lg font-bold">Course Content</h2> + <div className="flex items-center"> + {isLoading && ( + <Loader2 + size={16} + className="mr-2 animate-spin text-gray-400" + /> + )} + <button + onClick={() => setSidebarOpen(false)} + className="rounded-md p-1 hover:bg-gray-100 md:hidden" + > + <X size={18} /> + </button> + </div> + </div> + <div className="mt-2 text-sm text-gray-500"> + {totalModules} modules •{' '} + {streamedCourse.modules.reduce( + (total, module) => total + module.lessons.length, + 0, + )}{' '} + lessons + </div> + </div> + + {/* Module list */} + <nav className="space-y-1 px-2"> + {streamedCourse.modules.map((module, moduleIdx) => ( + <div key={moduleIdx} className="rounded-md"> + <button + onClick={() => toggleModule(moduleIdx)} + className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium ${ + activeModuleIndex === moduleIdx + ? 'bg-gray-100 text-gray-900' + : 'text-gray-700 hover:bg-gray-50' + }`} + > + <div className="flex min-w-0 items-start pr-2"> + <span className="mr-2 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold"> + {moduleIdx + 1} + </span> + <span className="break-words"> + {module.title?.replace(/^Module\s*?\d+[\.:]\s*/, '')} + </span> + </div> + {expandedModules[moduleIdx] ? ( + <ChevronDown size={16} className="flex-shrink-0" /> + ) : ( + <ChevronRight size={16} className="flex-shrink-0" /> + )} + </button> + + {/* Lessons */} + {expandedModules[moduleIdx] && ( + <div className="ml-8 mt-1 space-y-1"> + {module.lessons.map((lesson, lessonIdx) => ( + <button + key={lessonIdx} + onClick={() => { + setActiveModuleIndex(moduleIdx); + setActiveLessonIndex(lessonIdx); + // Expand only this module in the sidebar + setExpandedModules((prev) => { + const newState: Record<number, boolean> = {}; + // Set all modules to collapsed + streamedCourse.modules.forEach((_, idx) => { + newState[idx] = false; + }); + // Expand only the current module + newState[moduleIdx] = true; + return newState; + }); + // Ensure sidebar is visible on mobile + setSidebarOpen(true); + setViewMode('module'); + }} + className={`flex w-full items-start rounded-md px-3 py-2 text-left text-sm ${ + activeModuleIndex === moduleIdx && + activeLessonIndex === lessonIdx + ? 'bg-gray-800 text-white' + : 'text-gray-600 hover:bg-gray-50' + }`} + > + <span className="relative top-[2px] mr-2 flex-shrink-0 text-xs"> + {lessonIdx + 1}. + </span> + <span className="break-words"> + {lesson?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} + </span> + </button> + ))} + </div> + )} + </div> + ))} + </nav> + </aside> + + {/* Main content */} + <main + className={`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> + + {/* Navigation buttons */} + <div className="mt-8 flex items-center justify-between"> + <button + onClick={goToPrevLesson} + disabled={ + activeModuleIndex === 0 && activeLessonIndex === 0 + } + className={`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={`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 */ + <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> + {isLoading && ( + <Loader2 size={20} className="animate-spin text-gray-400" /> + )} + </div> + {streamedCourse.title ? ( + <div className="flex flex-col"> + {streamedCourse.modules.map((module, moduleIdx) => ( + <div + key={moduleIdx} + className="mb-5 pb-4 last:border-0 last:pb-0" + > + <h2 className="mb-2 text-xl font-bold text-gray-800"> + {module.title} + </h2> + <div className="ml-2 space-y-1"> + {module.lessons.map((lesson, lessonIdx) => ( + <div + key={lessonIdx} + className="flex cursor-pointer items-start rounded-md border border-gray-100 p-2 transition-colors hover:border-gray-300 hover:bg-blue-50" + onClick={() => { + setActiveModuleIndex(moduleIdx); + setActiveLessonIndex(lessonIdx); + // Expand only this module in the sidebar + setExpandedModules((prev) => { + const newState: Record<number, boolean> = {}; + // Set all modules to collapsed + streamedCourse.modules.forEach((_, idx) => { + newState[idx] = false; + }); + // Expand only the current module + newState[moduleIdx] = true; + return newState; + }); + // Ensure sidebar is visible on mobile + setSidebarOpen(true); + setViewMode('module'); + }} + > + <span className="mr-2 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700"> + {lessonIdx + 1} + </span> + <p className="flex-1 pt-0.5 text-gray-700"> + {lesson} + </p> + <span className="text-sm font-medium text-blue-600"> + View → + </span> + </div> + ))} + </div> + </div> + ))} + </div> + ) : ( + <div className="flex h-64 items-center justify-center"> + <Loader2 size={40} className="animate-spin text-gray-400" /> + </div> + )} + </div> + )} + </main> + </div> + + {/* Overlay for mobile sidebar */} + {sidebarOpen && ( + <div + className="fixed inset-0 z-10 bg-gray-900 bg-opacity-50 md:hidden" + onClick={() => setSidebarOpen(false)} + ></div> + )} + </section> + ); +} diff --git a/src/pages/ai-tutor/[courseId].astro b/src/pages/ai-tutor/[courseId].astro deleted file mode 100644 index 8994c773d..000000000 --- a/src/pages/ai-tutor/[courseId].astro +++ /dev/null @@ -1,16 +0,0 @@ ---- -import { AICourse } from '../../components/GenerateCourse/AICourse'; -import BaseLayout from '../../layouts/BaseLayout.astro'; - -export const prerender = false; - -interface Params extends Record<string, string | undefined> { - courseId: string; -} - -const { courseId } = Astro.params as Params; ---- - -<BaseLayout title='AI Tutor' noIndex={true}> - <AICourse courseId={courseId} client:load /> -</BaseLayout> diff --git a/src/pages/ai-tutor/search.astro b/src/pages/ai-tutor/search.astro new file mode 100644 index 000000000..f07799a73 --- /dev/null +++ b/src/pages/ai-tutor/search.astro @@ -0,0 +1,15 @@ +--- +import { AICourse } from '../../components/GenerateCourse/AICourse'; +import { AICourseContent } from '../../components/GenerateCourse/AICourseContent'; +import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; +--- + +<SkeletonLayout + title='AI Tutor' + briefTitle='AI Tutor' + description='AI Tutor' + keywords={['ai', 'tutor', 'education', 'learning']} + canonicalUrl='/ai-tutor/search' +> + <AICourseContent client:load /> +</SkeletonLayout>