feat/ai-courses
Arik Chakma 2 months ago
parent 03838ae888
commit 54738929a9
  1. 13
      src/api/ai-roadmap.ts
  2. 60
      src/components/GenerateCourse/AICourseContent.tsx
  3. 35
      src/components/GenerateCourse/AICourseModuleList.tsx
  4. 22
      src/components/GenerateCourse/AICourseModuleView.tsx
  5. 1
      src/components/GenerateCourse/GenerateAICourse.tsx
  6. 58
      src/components/GenerateCourse/GetAICourse.tsx
  7. 4
      src/pages/ai-tutor/[courseSlug].astro
  8. 34
      src/queries/ai-course.ts

@ -18,3 +18,16 @@ export function aiRoadmapApi(context: APIContext) {
}, },
}; };
} }
export interface AICourseDocument {
_id: string;
userId: string;
title: string;
slug?: string;
keyword: string;
difficulty: string;
data: string;
viewCount: number;
createdAt: Date;
updatedAt: Date;
}

@ -1,4 +1,11 @@
import { ArrowLeft, BookOpenCheck, Loader2, Menu, X } from 'lucide-react'; import {
ArrowLeft,
BookOpenCheck,
CheckCircleIcon,
Loader2,
Menu,
X,
} from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { ErrorIcon } from '../ReactIcons/ErrorIcon'; import { ErrorIcon } from '../ReactIcons/ErrorIcon';
@ -8,6 +15,8 @@ import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client'; import { queryClient } from '../../stores/query-client';
import { AICourseModuleList } from './AICourseModuleList'; import { AICourseModuleList } from './AICourseModuleList';
import { AICourseModuleView } from './AICourseModuleView'; import { AICourseModuleView } from './AICourseModuleView';
import { slugify } from '../../lib/slugger';
import { CheckIcon } from '../ReactIcons/CheckIcon';
type Lesson = string; type Lesson = string;
@ -26,28 +35,26 @@ type AICourseContentProps = {
courseSlug?: string; courseSlug?: string;
course: AiCourse; course: AiCourse;
isLoading: boolean; isLoading: boolean;
error?: string;
}; };
export function AICourseContent(props: AICourseContentProps) { export function AICourseContent(props: AICourseContentProps) {
const { course, courseSlug, isLoading } = props; const { course, courseSlug, isLoading, error } = props;
const [courseId, setCourseId] = useState('');
const [error, setError] = useState<string | null>(null);
const [activeModuleIndex, setActiveModuleIndex] = useState(0); const [activeModuleIndex, setActiveModuleIndex] = useState(0);
const [activeLessonIndex, setActiveLessonIndex] = useState(0); const [activeLessonIndex, setActiveLessonIndex] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [viewMode, setViewMode] = useState<'module' | 'full'>('full'); const [viewMode, setViewMode] = useState<'module' | 'full'>('full');
const [expandedModules, setExpandedModules] = useState<
Record<number, boolean>
>({});
const { data: aiCourseProgress } = useQuery( const { data: aiCourseProgress } = useQuery(
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }), getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
queryClient, queryClient,
); );
const [expandedModules, setExpandedModules] = useState<
Record<number, boolean>
>({});
// Navigation helpers // Navigation helpers
const goToNextModule = () => { const goToNextModule = () => {
if (activeModuleIndex < course.modules.length - 1) { if (activeModuleIndex < course.modules.length - 1) {
@ -192,6 +199,7 @@ export function AICourseContent(props: AICourseContentProps) {
<AICourseModuleList <AICourseModuleList
course={course} course={course}
courseSlug={courseSlug}
activeModuleIndex={activeModuleIndex} activeModuleIndex={activeModuleIndex}
setActiveModuleIndex={setActiveModuleIndex} setActiveModuleIndex={setActiveModuleIndex}
activeLessonIndex={activeLessonIndex} activeLessonIndex={activeLessonIndex}
@ -235,7 +243,8 @@ export function AICourseContent(props: AICourseContentProps) {
</div> </div>
{course.title ? ( {course.title ? (
<div className="flex flex-col"> <div className="flex flex-col">
{course.modules.map((module, moduleIdx) => ( {course.modules.map((module, moduleIdx) => {
return (
<div <div
key={moduleIdx} key={moduleIdx}
className="mb-5 pb-4 last:border-0 last:pb-0" className="mb-5 pb-4 last:border-0 last:pb-0"
@ -244,16 +253,22 @@ export function AICourseContent(props: AICourseContentProps) {
{module.title} {module.title}
</h2> </h2>
<div className="ml-2 space-y-1"> <div className="ml-2 space-y-1">
{module.lessons.map((lesson, lessonIdx) => ( {module.lessons.map((lesson, lessonIdx) => {
const key = `${slugify(module.title)}__${slugify(lesson)}`;
const isCompleted =
aiCourseProgress?.done.includes(key);
return (
<div <div
key={lessonIdx} key={key}
className="flex cursor-pointer items-start rounded-md border border-gray-100 p-2 transition-colors hover:border-gray-300 hover:bg-blue-50" 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={() => { onClick={() => {
setActiveModuleIndex(moduleIdx); setActiveModuleIndex(moduleIdx);
setActiveLessonIndex(lessonIdx); setActiveLessonIndex(lessonIdx);
// Expand only this module in the sidebar // Expand only this module in the sidebar
setExpandedModules((prev) => { setExpandedModules((prev) => {
const newState: Record<number, boolean> = {}; const newState: Record<number, boolean> =
{};
// Set all modules to collapsed // Set all modules to collapsed
course.modules.forEach((_, idx) => { course.modules.forEach((_, idx) => {
newState[idx] = false; newState[idx] = false;
@ -267,9 +282,20 @@ export function AICourseContent(props: AICourseContentProps) {
setViewMode('module'); 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"> {!isCompleted && (
<span
className={cn(
'mr-2 mt-0.5 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} {lessonIdx + 1}
</span> </span>
)}
{isCompleted && (
<CheckIcon additionalClasses="size-6 mt-0.5 mr-2 flex-shrink-0 text-green-500" />
)}
<p className="flex-1 pt-0.5 text-gray-700"> <p className="flex-1 pt-0.5 text-gray-700">
{lesson} {lesson}
</p> </p>
@ -277,10 +303,12 @@ export function AICourseContent(props: AICourseContentProps) {
View View
</span> </span>
</div> </div>
))} );
})}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
) : ( ) : (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">

@ -1,10 +1,20 @@
import { type Dispatch, type SetStateAction, useState } from 'react'; import { type Dispatch, type SetStateAction, useState } from 'react';
import type { AiCourse } from '../../lib/ai'; import type { AiCourse } from '../../lib/ai';
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react'; import {
CheckCircleIcon,
ChevronDownIcon,
ChevronRightIcon,
} from 'lucide-react';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { getAiCourseProgressOptions } from '../../queries/ai-course';
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { slugify } from '../../lib/slugger';
import { CheckIcon } from '../ReactIcons/CheckIcon';
type AICourseModuleListProps = { type AICourseModuleListProps = {
course: AiCourse; course: AiCourse;
courseSlug?: string;
activeModuleIndex: number; activeModuleIndex: number;
setActiveModuleIndex: (index: number) => void; setActiveModuleIndex: (index: number) => void;
activeLessonIndex: number; activeLessonIndex: number;
@ -22,6 +32,7 @@ type AICourseModuleListProps = {
export function AICourseModuleList(props: AICourseModuleListProps) { export function AICourseModuleList(props: AICourseModuleListProps) {
const { const {
course, course,
courseSlug,
activeModuleIndex, activeModuleIndex,
setActiveModuleIndex, setActiveModuleIndex,
activeLessonIndex, activeLessonIndex,
@ -32,6 +43,11 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
setExpandedModules, setExpandedModules,
} = props; } = props;
const { data: aiCourseProgress } = useQuery(
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
queryClient,
);
const toggleModule = (index: number) => { const toggleModule = (index: number) => {
setExpandedModules((prev) => { setExpandedModules((prev) => {
// If this module is already expanded, collapse it // If this module is already expanded, collapse it
@ -54,6 +70,8 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
}); });
}; };
const { done = [] } = aiCourseProgress || {};
return ( return (
<nav className="space-y-1 px-2"> <nav className="space-y-1 px-2">
{course.modules.map((module, moduleIdx) => ( {course.modules.map((module, moduleIdx) => (
@ -85,9 +103,13 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
{/* Lessons */} {/* Lessons */}
{expandedModules[moduleIdx] && ( {expandedModules[moduleIdx] && (
<div className="ml-8 mt-1 space-y-1"> <div className="ml-8 mt-1 space-y-1">
{module.lessons.map((lesson, lessonIdx) => ( {module.lessons.map((lesson, lessonIdx) => {
const key = `${slugify(module.title)}__${slugify(lesson)}`;
const isCompleted = done.includes(key);
return (
<button <button
key={lessonIdx} key={key}
onClick={() => { onClick={() => {
setActiveModuleIndex(moduleIdx); setActiveModuleIndex(moduleIdx);
setActiveLessonIndex(lessonIdx); setActiveLessonIndex(lessonIdx);
@ -114,14 +136,19 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
: 'text-gray-600 hover:bg-gray-50', : 'text-gray-600 hover:bg-gray-50',
)} )}
> >
{isCompleted ? (
<CheckIcon additionalClasses="size-3.5 relative top-[2px] mr-2 flex-shrink-0 text-green-500" />
) : (
<span className="relative top-[2px] mr-2 flex-shrink-0 text-xs"> <span className="relative top-[2px] mr-2 flex-shrink-0 text-xs">
{lessonIdx + 1}. {lessonIdx + 1}.
</span> </span>
)}
<span className="break-words"> <span className="break-words">
{lesson?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} {lesson?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
</span> </span>
</button> </button>
))} );
})}
</div> </div>
)} )}
</div> </div>

@ -4,10 +4,11 @@ import { useEffect, useState } from 'react';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt'; import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { readAICourseLessonStream } from '../../helper/read-stream'; import { readAICourseLessonStream } from '../../helper/read-stream';
import { markdownToHtml } from '../../lib/markdown'; import { markdownToHtml } from '../../lib/markdown';
import { useMutation } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client'; import { queryClient } from '../../stores/query-client';
import { httpPost } from '../../lib/query-http'; import { httpPost } from '../../lib/query-http';
import { slugify } from '../../lib/slugger'; import { slugify } from '../../lib/slugger';
import { getAiCourseProgressOptions } from '../../queries/ai-course';
type AICourseModuleViewProps = { type AICourseModuleViewProps = {
courseSlug: string; courseSlug: string;
@ -43,6 +44,13 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [lessonHtml, setLessonHtml] = useState(''); const [lessonHtml, setLessonHtml] = useState('');
const { data: aiCourseProgress } = useQuery(
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
queryClient,
);
const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`;
const isLessonDone = aiCourseProgress?.done.includes(lessonId);
const generateAiCourseContent = async () => { const generateAiCourseContent = async () => {
setIsLoading(true); setIsLoading(true);
@ -114,13 +122,9 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
const { mutate: markAsDone, isPending: isMarkingAsDone } = useMutation( const { mutate: markAsDone, isPending: isMarkingAsDone } = useMutation(
{ {
mutationFn: () => { mutationFn: () => {
const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`; return httpPost(`/v1-mark-as-done-ai-lesson/${courseSlug}`, {
return httpPost( lessonId,
`${import.meta.env.PUBLIC_API_URL}/v1-mark-as-done-ai-lesson/${courseSlug}`, });
{
lessonId: lessonId,
},
);
}, },
}, },
queryClient, queryClient,
@ -162,7 +166,7 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
</div> </div>
)} )}
{!isGenerating && !isLoading && ( {!isGenerating && !isLoading && !isLessonDone && (
<button <button
className="rounded-md bg-blue-500 px-4 py-1 text-white hover:bg-blue-600 disabled:opacity-50" className="rounded-md bg-blue-500 px-4 py-1 text-white hover:bg-blue-600 disabled:opacity-50"
disabled={isMarkingAsDone} disabled={isMarkingAsDone}

@ -182,6 +182,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
courseSlug={courseSlug} courseSlug={courseSlug}
course={course} course={course}
isLoading={isLoading} isLoading={isLoading}
error={error}
/> />
); );
} }

@ -0,0 +1,58 @@
import { useQuery } from '@tanstack/react-query';
import { getAiCourseOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { useEffect, useState } from 'react';
import { AICourseContent } from './AICourseContent';
import { generateAiCourseStructure } from '../../lib/ai';
type GetAICourseProps = {
courseSlug: string;
};
export function GetAICourse(props: GetAICourseProps) {
const { courseSlug } = props;
const [isLoading, setIsLoading] = useState(true);
const { data: aiCourse, error } = useQuery(
{
...getAiCourseOptions({ aiCourseSlug: courseSlug }),
select: (data) => {
return {
...data,
course: generateAiCourseStructure(data.data),
};
},
enabled: !!courseSlug,
},
queryClient,
);
useEffect(() => {
if (!aiCourse) {
return;
}
setIsLoading(false);
}, [aiCourse]);
useEffect(() => {
if (!error) {
return;
}
setIsLoading(false);
}, [error]);
return (
<AICourseContent
course={{
title: aiCourse?.title || '',
modules: aiCourse?.course.modules || [],
difficulty: aiCourse?.difficulty || 'Easy',
}}
isLoading={isLoading}
courseSlug={courseSlug}
error={error?.message}
/>
);
}

@ -1,5 +1,5 @@
--- ---
import { AICourseContent } from '../../components/GenerateCourse/AICourseContent'; import { GetAICourse } from '../../components/GenerateCourse/GetAICourse';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
export const prerender = false; export const prerender = false;
@ -18,5 +18,5 @@ const { courseSlug } = Astro.params as Params;
keywords={['ai', 'tutor', 'education', 'learning']} keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl={`/ai-tutor/${courseSlug}`} canonicalUrl={`/ai-tutor/${courseSlug}`}
> >
<AICourseContent client:load slug={courseSlug} /> <GetAICourse client:load courseSlug={courseSlug} />
</SkeletonLayout> </SkeletonLayout>

@ -31,3 +31,37 @@ export function getAiCourseProgressOptions(params: GetAICourseProgressParams) {
enabled: !!params.aiCourseSlug && isLoggedIn(), enabled: !!params.aiCourseSlug && isLoggedIn(),
}; };
} }
type GetAICourseParams = {
aiCourseSlug: string;
};
export interface AICourseDocument {
_id: string;
userId: string;
title: string;
slug?: string;
keyword: string;
difficulty: string;
data: string;
viewCount: number;
createdAt: Date;
updatedAt: Date;
}
type GetAICourseBody = {};
type GetAICourseQuery = {};
type GetAICourseResponse = AICourseDocument;
export function getAiCourseOptions(params: GetAICourseParams) {
return {
queryKey: ['ai-course', params],
queryFn: () => {
return httpGet<GetAICourseResponse>(
`/v1-get-ai-course/${params.aiCourseSlug}`,
);
},
};
}

Loading…
Cancel
Save