From 03838ae888dc7fd9e65640651e5f00a39b12b1f9 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Mon, 3 Mar 2025 18:54:52 +0600 Subject: [PATCH] wip: ai course progress --- .../GenerateCourse/AICourseContent.tsx | 376 +++--------------- .../GenerateCourse/AICourseModuleList.tsx | 131 ++++++ .../GenerateCourse/AICourseModuleView.tsx | 2 +- .../GenerateCourse/GenerateAICourse.tsx | 187 +++++++++ src/pages/ai-tutor/search.astro | 5 +- src/queries/ai-course.ts | 33 ++ 6 files changed, 401 insertions(+), 333 deletions(-) create mode 100644 src/components/GenerateCourse/AICourseModuleList.tsx create mode 100644 src/components/GenerateCourse/GenerateAICourse.tsx create mode 100644 src/queries/ai-course.ts diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index becefaf90..2400b54b5 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -1,21 +1,13 @@ -import { - ArrowLeft, - BookOpenCheck, - ChevronDown, - ChevronRight, - Loader2, - Menu, - X, -} from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { readAICourseStream } from '../../helper/read-stream'; +import { ArrowLeft, BookOpenCheck, Loader2, Menu, X } from 'lucide-react'; +import { useState } from 'react'; import { cn } from '../../lib/classname'; -import { getUrlParams } from '../../lib/browser'; -import { AICourseModuleView } from './AICourseModuleView'; -import { showLoginPopup } from '../../lib/popup'; -import { isLoggedIn } from '../../lib/jwt'; import { ErrorIcon } from '../ReactIcons/ErrorIcon'; -import { generateAiCourseStructure } from '../../lib/ai'; +import { type AiCourse } from '../../lib/ai'; +import { getAiCourseProgressOptions } from '../../queries/ai-course'; +import { useQuery } from '@tanstack/react-query'; +import { queryClient } from '../../stores/query-client'; +import { AICourseModuleList } from './AICourseModuleList'; +import { AICourseModuleView } from './AICourseModuleView'; type Lesson = string; @@ -31,218 +23,34 @@ type Course = { }; type AICourseContentProps = { - slug?: string; - term?: string; - difficulty?: string; + courseSlug?: string; + course: AiCourse; + isLoading: boolean; }; export function AICourseContent(props: AICourseContentProps) { - const { slug: defaultSlug } = props; - - const [term, setTerm] = useState(''); - const [difficulty, setDifficulty] = useState('beginner'); - - const [courseSlug, setCourseSlug] = useState(defaultSlug || ''); + const { course, courseSlug, isLoading } = props; const [courseId, setCourseId] = useState(''); - const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [streamedCourse, setStreamedCourse] = useState<{ - title: string; - modules: Module[]; - }>({ - title: '', - modules: [], - }); - const [expandedModules, setExpandedModules] = useState< - Record - >({}); 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 = {}; - // Set all modules to collapsed - streamedCourse.modules.forEach((_, idx) => { - newState[idx] = false; - }); - // Expand only the clicked module - newState[index] = true; - return newState; - }); - }; - - useEffect(() => { - if (!defaultSlug) { - return; - } - - generateCourse({ slug: defaultSlug }); - }, [defaultSlug]); - - useEffect(() => { - if (term || courseSlug) { - return; - } - - const params = getUrlParams(); - const paramsTerm = params?.term; - const paramsDifficulty = params?.difficulty; - if (!paramsTerm || !paramsDifficulty) { - return; - } - - setTerm(paramsTerm); - setDifficulty(paramsDifficulty); - }, [term, difficulty, courseSlug]); - - const getAiCourseResponse = async (options: { - slug?: string; - term?: string; - difficulty?: string; - }): Promise => { - const { slug, term, difficulty } = options; - - if (slug) { - return fetch( - `${import.meta.env.PUBLIC_API_URL}/v1-get-ai-course/${slug}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - credentials: 'include', - }, - ); - } - - return 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', - }); - }; - - const generateCourse = async (options: { - slug?: string; - term?: string; - difficulty?: string; - }) => { - const { slug, term, difficulty } = options; - - if (!isLoggedIn() && !slug) { - setIsLoading(false); - setError('You must be logged in to generate a course'); - showLoginPopup(); - return; - } - - setIsLoading(true); - setStreamedCourse({ title: '', modules: [] }); - setExpandedModules({}); - setViewMode('full'); - setError(null); - - try { - const response = await getAiCourseResponse({ slug, term, difficulty }); - - 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 && !defaultSlug) { - window.history.replaceState( - { - courseId, - courseSlug: extractedCourseSlug, - }, - '', - `${origin}/ai-tutor/${extractedCourseSlug}`, - ); - } - - result = result - .replace(COURSE_ID_REGEX, '') - .replace(COURSE_SLUG_REGEX, ''); - - setCourseId(extractedCourseId); - setCourseSlug(extractedCourseSlug); - } + const [expandedModules, setExpandedModules] = useState< + Record + >({}); - try { - const aiCourse = generateAiCourseStructure(result); - setStreamedCourse({ - title: aiCourse.title, - modules: aiCourse.modules, - }); - } 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); - } - }; + const { data: aiCourseProgress } = useQuery( + getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }), + queryClient, + ); // Navigation helpers const goToNextModule = () => { - if (activeModuleIndex < streamedCourse.modules.length - 1) { + if (activeModuleIndex < course.modules.length - 1) { const nextModuleIndex = activeModuleIndex + 1; setActiveModuleIndex(nextModuleIndex); setActiveLessonIndex(0); @@ -251,7 +59,7 @@ export function AICourseContent(props: AICourseContentProps) { setExpandedModules((prev) => { const newState: Record = {}; // Set all modules to collapsed - streamedCourse.modules.forEach((_, idx) => { + course.modules.forEach((_, idx) => { newState[idx] = false; }); // Expand only the next module @@ -262,7 +70,7 @@ export function AICourseContent(props: AICourseContentProps) { }; const goToNextLesson = () => { - const currentModule = streamedCourse.modules[activeModuleIndex]; + const currentModule = course.modules[activeModuleIndex]; if (currentModule && activeLessonIndex < currentModule.lessons.length - 1) { setActiveLessonIndex(activeLessonIndex + 1); } else { @@ -274,7 +82,7 @@ export function AICourseContent(props: AICourseContentProps) { if (activeLessonIndex > 0) { setActiveLessonIndex(activeLessonIndex - 1); } else { - const prevModule = streamedCourse.modules[activeModuleIndex - 1]; + const prevModule = course.modules[activeModuleIndex - 1]; if (prevModule) { const prevModuleIndex = activeModuleIndex - 1; setActiveModuleIndex(prevModuleIndex); @@ -284,7 +92,7 @@ export function AICourseContent(props: AICourseContentProps) { setExpandedModules((prev) => { const newState: Record = {}; // Set all modules to collapsed - streamedCourse.modules.forEach((_, idx) => { + course.modules.forEach((_, idx) => { newState[idx] = false; }); // Expand only the previous module @@ -295,32 +103,9 @@ export function AICourseContent(props: AICourseContentProps) { } }; - useEffect(() => { - const handlePopState = (e: PopStateEvent) => { - const { courseId, courseSlug } = e.state || {}; - if (!courseId || !courseSlug) { - window.location.reload(); - return; - } - - setCourseId(courseId); - setCourseSlug(courseSlug); - - setIsLoading(true); - generateCourse({ slug: courseSlug }).finally(() => { - setIsLoading(false); - }); - }; - - window.addEventListener('popstate', handlePopState); - return () => { - window.removeEventListener('popstate', handlePopState); - }; - }, []); - - const currentModule = streamedCourse.modules[activeModuleIndex]; + const currentModule = course.modules[activeModuleIndex]; const currentLesson = currentModule?.lessons[activeLessonIndex]; - const totalModules = streamedCourse.modules.length; + const totalModules = course.modules.length; const totalLessons = currentModule?.lessons.length || 0; if (error && !isLoading) { @@ -337,26 +122,21 @@ export function AICourseContent(props: AICourseContentProps) {
- +

- {streamedCourse.title || 'Loading Course...'} + {course.title || 'Loading Course...'}

{viewMode === 'module' && ( - - {/* Lessons */} - {expandedModules[moduleIdx] && ( -
- {module.lessons.map((lesson, lessonIdx) => ( - - ))} -
- )} -
- ))} - +
{viewMode === 'module' && ( )} - {streamedCourse.title ? ( + {course.title ? (
- {streamedCourse.modules.map((module, moduleIdx) => ( + {course.modules.map((module, moduleIdx) => (
{ const newState: Record = {}; // Set all modules to collapsed - streamedCourse.modules.forEach((_, idx) => { + course.modules.forEach((_, idx) => { newState[idx] = false; }); // Expand only the current module diff --git a/src/components/GenerateCourse/AICourseModuleList.tsx b/src/components/GenerateCourse/AICourseModuleList.tsx new file mode 100644 index 000000000..61cd2a9d9 --- /dev/null +++ b/src/components/GenerateCourse/AICourseModuleList.tsx @@ -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; + setExpandedModules: Dispatch>>; +}; + +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 = {}; + // Set all modules to collapsed + course.modules.forEach((_, idx) => { + newState[idx] = false; + }); + // Expand only the clicked module + newState[index] = true; + return newState; + }); + }; + + return ( + + ); +} diff --git a/src/components/GenerateCourse/AICourseModuleView.tsx b/src/components/GenerateCourse/AICourseModuleView.tsx index c35ee78a1..6b018e8e6 100644 --- a/src/components/GenerateCourse/AICourseModuleView.tsx +++ b/src/components/GenerateCourse/AICourseModuleView.tsx @@ -114,7 +114,7 @@ export function AICourseModuleView(props: AICourseModuleViewProps) { const { mutate: markAsDone, isPending: isMarkingAsDone } = useMutation( { mutationFn: () => { - const lessonId = `${slugify(currentModuleTitle)}-${slugify(currentLessonTitle)}`; + const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`; return httpPost( `${import.meta.env.PUBLIC_API_URL}/v1-mark-as-done-ai-lesson/${courseSlug}`, { diff --git a/src/components/GenerateCourse/GenerateAICourse.tsx b/src/components/GenerateCourse/GenerateAICourse.tsx new file mode 100644 index 000000000..cca772bc1 --- /dev/null +++ b/src/components/GenerateCourse/GenerateAICourse.tsx @@ -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({ + 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 ( + + ); +} diff --git a/src/pages/ai-tutor/search.astro b/src/pages/ai-tutor/search.astro index f07799a73..4b7602a1a 100644 --- a/src/pages/ai-tutor/search.astro +++ b/src/pages/ai-tutor/search.astro @@ -1,6 +1,5 @@ --- -import { AICourse } from '../../components/GenerateCourse/AICourse'; -import { AICourseContent } from '../../components/GenerateCourse/AICourseContent'; +import { GenerateAICourse } from '../../components/GenerateCourse/GenerateAICourse'; import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; --- @@ -11,5 +10,5 @@ import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; keywords={['ai', 'tutor', 'education', 'learning']} canonicalUrl='/ai-tutor/search' > - + diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts new file mode 100644 index 000000000..8a6ffc041 --- /dev/null +++ b/src/queries/ai-course.ts @@ -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( + `/v1-get-ai-course-progress/${params.aiCourseSlug}`, + ); + }, + enabled: !!params.aiCourseSlug && isLoggedIn(), + }; +}