parent
38411a276e
commit
f86e2ca741
4 changed files with 669 additions and 693 deletions
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
|
@ -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> |
Loading…
Reference in new issue