parent
9518b66f10
commit
03838ae888
6 changed files with 401 additions and 333 deletions
@ -0,0 +1,131 @@ |
||||
import { type Dispatch, type SetStateAction, useState } from 'react'; |
||||
import type { AiCourse } from '../../lib/ai'; |
||||
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react'; |
||||
import { cn } from '../../lib/classname'; |
||||
|
||||
type AICourseModuleListProps = { |
||||
course: AiCourse; |
||||
activeModuleIndex: number; |
||||
setActiveModuleIndex: (index: number) => void; |
||||
activeLessonIndex: number; |
||||
setActiveLessonIndex: (index: number) => void; |
||||
|
||||
setSidebarOpen: (open: boolean) => void; |
||||
|
||||
viewMode: 'module' | 'full'; |
||||
setViewMode: (mode: 'module' | 'full') => void; |
||||
|
||||
expandedModules: Record<number, boolean>; |
||||
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>; |
||||
}; |
||||
|
||||
export function AICourseModuleList(props: AICourseModuleListProps) { |
||||
const { |
||||
course, |
||||
activeModuleIndex, |
||||
setActiveModuleIndex, |
||||
activeLessonIndex, |
||||
setActiveLessonIndex, |
||||
setSidebarOpen, |
||||
setViewMode, |
||||
expandedModules, |
||||
setExpandedModules, |
||||
} = props; |
||||
|
||||
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
|
||||
course.modules.forEach((_, idx) => { |
||||
newState[idx] = false; |
||||
}); |
||||
// Expand only the clicked module
|
||||
newState[index] = true; |
||||
return newState; |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<nav className="space-y-1 px-2"> |
||||
{course.modules.map((module, moduleIdx) => ( |
||||
<div key={moduleIdx} className="rounded-md"> |
||||
<button |
||||
onClick={() => toggleModule(moduleIdx)} |
||||
className={cn( |
||||
'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] ? ( |
||||
<ChevronDownIcon size={16} className="flex-shrink-0" /> |
||||
) : ( |
||||
<ChevronRightIcon 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
|
||||
course.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={cn( |
||||
'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> |
||||
); |
||||
} |
@ -0,0 +1,187 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { getUrlParams } from '../../lib/browser'; |
||||
import { isLoggedIn } from '../../lib/jwt'; |
||||
import { showLoginPopup } from '../../lib/popup'; |
||||
import { generateAiCourseStructure, type AiCourse } from '../../lib/ai'; |
||||
import { readAICourseStream } from '../../helper/read-stream'; |
||||
import { AICourseContent } from './AICourseContent'; |
||||
|
||||
type GenerateAICourseProps = {}; |
||||
|
||||
export function GenerateAICourse(props: GenerateAICourseProps) { |
||||
const [term, setTerm] = useState(''); |
||||
const [difficulty, setDifficulty] = useState(''); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [error, setError] = useState(''); |
||||
|
||||
const [courseId, setCourseId] = useState(''); |
||||
const [courseSlug, setCourseSlug] = useState(''); |
||||
const [course, setCourse] = useState<AiCourse>({ |
||||
title: '', |
||||
modules: [], |
||||
difficulty: '', |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
if (term || difficulty) { |
||||
return; |
||||
} |
||||
|
||||
const params = getUrlParams(); |
||||
const paramsTerm = params?.term; |
||||
const paramsDifficulty = params?.difficulty; |
||||
if (!paramsTerm || !paramsDifficulty) { |
||||
return; |
||||
} |
||||
|
||||
setTerm(paramsTerm); |
||||
setDifficulty(paramsDifficulty); |
||||
generateCourse({ term: paramsTerm, difficulty: paramsDifficulty }); |
||||
}, [term, difficulty]); |
||||
|
||||
const generateCourse = async (options: { |
||||
term: string; |
||||
difficulty: string; |
||||
}) => { |
||||
const { term, difficulty } = options; |
||||
|
||||
if (!isLoggedIn()) { |
||||
setIsLoading(false); |
||||
setError('You must be logged in to generate a course'); |
||||
showLoginPopup(); |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(true); |
||||
setCourse({ |
||||
title: '', |
||||
modules: [], |
||||
difficulty: '', |
||||
}); |
||||
setError(''); |
||||
|
||||
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); |
||||
setError(data?.message || 'Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
const reader = response.body?.getReader(); |
||||
|
||||
if (!reader) { |
||||
console.error('Failed to get reader from response'); |
||||
setError('Something went wrong'); |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@'); |
||||
const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/); |
||||
|
||||
await readAICourseStream(reader, { |
||||
onStream: (result) => { |
||||
if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) { |
||||
const courseIdMatch = result.match(COURSE_ID_REGEX); |
||||
const courseSlugMatch = result.match(COURSE_SLUG_REGEX); |
||||
const extractedCourseId = courseIdMatch?.[1] || ''; |
||||
const extractedCourseSlug = courseSlugMatch?.[1] || ''; |
||||
|
||||
if (extractedCourseSlug) { |
||||
window.history.replaceState( |
||||
{ |
||||
courseId, |
||||
courseSlug: extractedCourseSlug, |
||||
term, |
||||
difficulty, |
||||
}, |
||||
'', |
||||
`${origin}/ai-tutor/${extractedCourseSlug}`, |
||||
); |
||||
} |
||||
|
||||
result = result |
||||
.replace(COURSE_ID_REGEX, '') |
||||
.replace(COURSE_SLUG_REGEX, ''); |
||||
|
||||
setCourseId(extractedCourseId); |
||||
} |
||||
|
||||
try { |
||||
const aiCourse = generateAiCourseStructure(result); |
||||
setCourse({ |
||||
...aiCourse, |
||||
difficulty: difficulty || '', |
||||
}); |
||||
} catch (e) { |
||||
console.error('Error parsing streamed course content:', e); |
||||
} |
||||
}, |
||||
onStreamEnd: (result) => { |
||||
result = result |
||||
.replace(COURSE_ID_REGEX, '') |
||||
.replace(COURSE_SLUG_REGEX, ''); |
||||
setIsLoading(false); |
||||
}, |
||||
}); |
||||
} catch (error: any) { |
||||
setError(error?.message || 'Something went wrong'); |
||||
console.error('Error in course generation:', error); |
||||
setIsLoading(false); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
const handlePopState = (e: PopStateEvent) => { |
||||
const { courseId, courseSlug, term, difficulty } = e.state || {}; |
||||
if (!courseId || !courseSlug) { |
||||
window.location.reload(); |
||||
return; |
||||
} |
||||
|
||||
setCourseId(courseId); |
||||
setCourseSlug(courseSlug); |
||||
setTerm(term); |
||||
setDifficulty(difficulty); |
||||
|
||||
setIsLoading(true); |
||||
generateCourse({ term, difficulty }).finally(() => { |
||||
setIsLoading(false); |
||||
}); |
||||
}; |
||||
|
||||
window.addEventListener('popstate', handlePopState); |
||||
return () => { |
||||
window.removeEventListener('popstate', handlePopState); |
||||
}; |
||||
}, []); |
||||
|
||||
return ( |
||||
<AICourseContent |
||||
courseSlug={courseSlug} |
||||
course={course} |
||||
isLoading={isLoading} |
||||
/> |
||||
); |
||||
} |
@ -0,0 +1,33 @@ |
||||
import { httpGet } from '../lib/query-http'; |
||||
import { isLoggedIn } from '../lib/jwt'; |
||||
|
||||
export interface AICourseProgressDocument { |
||||
_id: string; |
||||
userId: string; |
||||
courseId: string; |
||||
done: string[]; |
||||
createdAt: Date; |
||||
updatedAt: Date; |
||||
} |
||||
|
||||
type GetAICourseProgressParams = { |
||||
aiCourseSlug: string; |
||||
}; |
||||
|
||||
type GetAICourseProgressBody = {}; |
||||
|
||||
type GetAICourseProgressQuery = {}; |
||||
|
||||
type GetAICourseProgressResponse = AICourseProgressDocument; |
||||
|
||||
export function getAiCourseProgressOptions(params: GetAICourseProgressParams) { |
||||
return { |
||||
queryKey: ['ai-course-progress', params], |
||||
queryFn: () => { |
||||
return httpGet<GetAICourseProgressResponse>( |
||||
`/v1-get-ai-course-progress/${params.aiCourseSlug}`, |
||||
); |
||||
}, |
||||
enabled: !!params.aiCourseSlug && isLoggedIn(), |
||||
}; |
||||
} |
Loading…
Reference in new issue