feat/ai-courses
Arik Chakma 2 months ago
parent 54738929a9
commit 7e5c09fe8a
  1. 49
      src/components/GenerateCourse/AICourseModuleList.tsx
  2. 29
      src/components/GenerateCourse/AICourseModuleView.tsx
  3. 57
      src/components/GenerateCourse/CircularProgress.tsx

@ -1,16 +1,13 @@
import { type Dispatch, type SetStateAction, useState } from 'react';
import type { AiCourse } from '../../lib/ai';
import {
CheckCircleIcon,
ChevronDownIcon,
ChevronRightIcon,
} from 'lucide-react';
import { Check, 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';
import { CircularProgress } from './CircularProgress';
type AICourseModuleListProps = {
course: AiCourse;
@ -43,7 +40,7 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
setExpandedModules,
} = props;
const { data: aiCourseProgress } = useQuery(
const { data: aiCourseProgress, isLoading } = useQuery(
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
queryClient,
);
@ -74,7 +71,18 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
return (
<nav className="space-y-1 px-2">
{course.modules.map((module, moduleIdx) => (
{course.modules.map((module, moduleIdx) => {
const totalLessons = module.lessons.length;
const completedLessons = module.lessons.filter((lesson) => {
const key = `${slugify(module.title)}__${slugify(lesson)}`;
return done.includes(key);
}).length;
const percentage = Math.round((completedLessons / totalLessons) * 100);
const isActive = expandedModules[moduleIdx];
const isModuleCompleted = completedLessons === totalLessons;
return (
<div key={moduleIdx} className="rounded-md">
<button
onClick={() => toggleModule(moduleIdx)}
@ -86,10 +94,28 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
)}
>
<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}
<CircularProgress
percentage={percentage}
isVisible={!isModuleCompleted}
isActive={isActive}
isLoading={isLoading}
>
<span
className={cn(
'flex size-[21px] flex-shrink-0 items-center justify-center rounded-full bg-gray-400/70 text-xs font-semibold text-white',
{
'bg-black': isActive,
'bg-green-600': isModuleCompleted,
},
)}
>
{!isModuleCompleted && moduleIdx + 1}
{isModuleCompleted && (
<Check className="h-3 w-3 stroke-[3] text-white" />
)}
</span>
<span className="break-words">
</CircularProgress>
<span className="ml-2 break-words">
{module.title?.replace(/^Module\s*?\d+[\.:]\s*/, '')}
</span>
</div>
@ -152,7 +178,8 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
</div>
)}
</div>
))}
);
})}
</nav>
);
}

@ -1,6 +1,6 @@
import { ChevronLeft, ChevronRight, Loader2Icon, LockIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { readAICourseLessonStream } from '../../helper/read-stream';
import { markdownToHtml } from '../../lib/markdown';
@ -52,6 +52,11 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`;
const isLessonDone = aiCourseProgress?.done.includes(lessonId);
const abortController = useMemo(
() => new AbortController(),
[activeModuleIndex, activeLessonIndex],
);
const generateAiCourseContent = async () => {
setIsLoading(true);
setError('');
@ -76,6 +81,7 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
headers: {
'Content-Type': 'application/json',
},
signal: abortController.signal,
credentials: 'include',
body: JSON.stringify({
moduleTitle: currentModuleTitle,
@ -111,9 +117,17 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
setIsGenerating(true);
await readAICourseLessonStream(reader, {
onStream: async (result) => {
if (abortController.signal.aborted) {
return;
}
setLessonHtml(markdownToHtml(result, false));
},
onStreamEnd: () => {
if (abortController.signal.aborted) {
return;
}
setIsGenerating(false);
},
});
@ -126,6 +140,13 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
lessonId,
});
},
onSuccess: () => {
queryClient.invalidateQueries(
getAiCourseProgressOptions({
aiCourseSlug: courseSlug || '',
}),
);
},
},
queryClient,
);
@ -134,6 +155,12 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
generateAiCourseContent();
}, [currentModuleTitle, currentLessonTitle]);
useEffect(() => {
return () => {
abortController.abort();
};
}, [abortController]);
return (
<div className="mx-auto max-w-4xl">
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">

@ -0,0 +1,57 @@
import { cn } from '../../lib/classname';
export function ChapterNumberSkeleton() {
return (
<div className="h-[28px] w-[28px] animate-pulse rounded-full bg-gray-200" />
);
}
type CircularProgressProps = {
percentage: number;
children: React.ReactNode;
isVisible?: boolean;
isActive?: boolean;
isLoading?: boolean;
};
export function CircularProgress(props: CircularProgressProps) {
const {
percentage,
children,
isVisible = true,
isActive = false,
isLoading = false,
} = props;
const circumference = 2 * Math.PI * 13;
const strokeDasharray = `${circumference}`;
const strokeDashoffset = circumference - (percentage / 100) * circumference;
return (
<div className="relative flex h-[28px] w-[28px] flex-shrink-0 items-center justify-center">
{isVisible && !isLoading && (
<svg className="absolute h-full w-full -rotate-90">
<circle
cx="14"
cy="14"
r="13"
stroke="currentColor"
strokeWidth="1.75"
fill="none"
className={cn('text-gray-400/70', {
'text-black': isActive,
})}
style={{
strokeDasharray,
strokeDashoffset,
transition: 'stroke-dashoffset 0.3s ease',
}}
/>
</svg>
)}
{!isLoading && children}
{isLoading && <ChapterNumberSkeleton />}
</div>
);
}
Loading…
Cancel
Save