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