Add ai course page

feat/ai-courses
Kamran Ahmed 2 months ago
parent 38411a276e
commit f86e2ca741
  1. 684
      src/components/GenerateCourse/AICourse.tsx
  2. 647
      src/components/GenerateCourse/AICourseContent.tsx
  3. 16
      src/pages/ai-tutor/[courseId].astro
  4. 15
      src/pages/ai-tutor/search.astro

@ -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">

@ -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…
Cancel
Save