feat/ai-courses
Arik Chakma 2 months ago
parent 03838ae888
commit 54738929a9
  1. 13
      src/api/ai-roadmap.ts
  2. 136
      src/components/GenerateCourse/AICourseContent.tsx
  3. 101
      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 { cn } from '../../lib/classname';
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
@ -8,6 +15,8 @@ import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { AICourseModuleList } from './AICourseModuleList';
import { AICourseModuleView } from './AICourseModuleView';
import { slugify } from '../../lib/slugger';
import { CheckIcon } from '../ReactIcons/CheckIcon';
type Lesson = string;
@ -26,28 +35,26 @@ type AICourseContentProps = {
courseSlug?: string;
course: AiCourse;
isLoading: boolean;
error?: string;
};
export function AICourseContent(props: AICourseContentProps) {
const { course, courseSlug, isLoading } = props;
const [courseId, setCourseId] = useState('');
const [error, setError] = useState<string | null>(null);
const { course, courseSlug, isLoading, error } = props;
const [activeModuleIndex, setActiveModuleIndex] = useState(0);
const [activeLessonIndex, setActiveLessonIndex] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [viewMode, setViewMode] = useState<'module' | 'full'>('full');
const [expandedModules, setExpandedModules] = useState<
Record<number, boolean>
>({});
const { data: aiCourseProgress } = useQuery(
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
queryClient,
);
const [expandedModules, setExpandedModules] = useState<
Record<number, boolean>
>({});
// Navigation helpers
const goToNextModule = () => {
if (activeModuleIndex < course.modules.length - 1) {
@ -192,6 +199,7 @@ export function AICourseContent(props: AICourseContentProps) {
<AICourseModuleList
course={course}
courseSlug={courseSlug}
activeModuleIndex={activeModuleIndex}
setActiveModuleIndex={setActiveModuleIndex}
activeLessonIndex={activeLessonIndex}
@ -235,52 +243,72 @@ export function AICourseContent(props: AICourseContentProps) {
</div>
{course.title ? (
<div className="flex flex-col">
{course.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
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');
}}
>
<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>
))}
{course.modules.map((module, moduleIdx) => {
return (
<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) => {
const key = `${slugify(module.title)}__${slugify(lesson)}`;
const isCompleted =
aiCourseProgress?.done.includes(key);
return (
<div
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"
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');
}}
>
{!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}
</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">
{lesson}
</p>
<span className="text-sm font-medium text-blue-600">
View
</span>
</div>
);
})}
</div>
</div>
</div>
))}
);
})}
</div>
) : (
<div className="flex h-64 items-center justify-center">

@ -1,10 +1,20 @@
import { type Dispatch, type SetStateAction, useState } from 'react';
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 { 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 = {
course: AiCourse;
courseSlug?: string;
activeModuleIndex: number;
setActiveModuleIndex: (index: number) => void;
activeLessonIndex: number;
@ -22,6 +32,7 @@ type AICourseModuleListProps = {
export function AICourseModuleList(props: AICourseModuleListProps) {
const {
course,
courseSlug,
activeModuleIndex,
setActiveModuleIndex,
activeLessonIndex,
@ -32,6 +43,11 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
setExpandedModules,
} = props;
const { data: aiCourseProgress } = useQuery(
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
queryClient,
);
const toggleModule = (index: number) => {
setExpandedModules((prev) => {
// If this module is already expanded, collapse it
@ -54,6 +70,8 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
});
};
const { done = [] } = aiCourseProgress || {};
return (
<nav className="space-y-1 px-2">
{course.modules.map((module, moduleIdx) => (
@ -85,43 +103,52 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
{/* 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;
{module.lessons.map((lesson, lessonIdx) => {
const key = `${slugify(module.title)}__${slugify(lesson)}`;
const isCompleted = done.includes(key);
return (
<button
key={key}
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;
});
// 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>
))}
// 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',
)}
>
{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">
{lessonIdx + 1}.
</span>
)}
<span className="break-words">
{lesson?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
</span>
</button>
);
})}
</div>
)}
</div>

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

@ -182,6 +182,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
courseSlug={courseSlug}
course={course}
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';
export const prerender = false;
@ -18,5 +18,5 @@ const { courseSlug } = Astro.params as Params;
keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl={`/ai-tutor/${courseSlug}`}
>
<AICourseContent client:load slug={courseSlug} />
<GetAICourse client:load courseSlug={courseSlug} />
</SkeletonLayout>

@ -31,3 +31,37 @@ export function getAiCourseProgressOptions(params: GetAICourseProgressParams) {
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