feat/ai-courses
Arik Chakma 2 months ago
parent 54738929a9
commit 7e5c09fe8a
  1. 185
      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 Dispatch, type SetStateAction, useState } from 'react';
import type { AiCourse } from '../../lib/ai'; import type { AiCourse } from '../../lib/ai';
import { import { Check, ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
CheckCircleIcon,
ChevronDownIcon,
ChevronRightIcon,
} from 'lucide-react';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { getAiCourseProgressOptions } from '../../queries/ai-course'; import { getAiCourseProgressOptions } from '../../queries/ai-course';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client'; import { queryClient } from '../../stores/query-client';
import { slugify } from '../../lib/slugger'; import { slugify } from '../../lib/slugger';
import { CheckIcon } from '../ReactIcons/CheckIcon'; import { CheckIcon } from '../ReactIcons/CheckIcon';
import { CircularProgress } from './CircularProgress';
type AICourseModuleListProps = { type AICourseModuleListProps = {
course: AiCourse; course: AiCourse;
@ -43,7 +40,7 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
setExpandedModules, setExpandedModules,
} = props; } = props;
const { data: aiCourseProgress } = useQuery( const { data: aiCourseProgress, isLoading } = useQuery(
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }), getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
queryClient, queryClient,
); );
@ -74,85 +71,115 @@ export function AICourseModuleList(props: AICourseModuleListProps) {
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) => {
<div key={moduleIdx} className="rounded-md"> const totalLessons = module.lessons.length;
<button const completedLessons = module.lessons.filter((lesson) => {
onClick={() => toggleModule(moduleIdx)} const key = `${slugify(module.title)}__${slugify(lesson)}`;
className={cn( return done.includes(key);
'flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium', }).length;
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 */} const percentage = Math.round((completedLessons / totalLessons) * 100);
{expandedModules[moduleIdx] && ( const isActive = expandedModules[moduleIdx];
<div className="ml-8 mt-1 space-y-1"> const isModuleCompleted = completedLessons === totalLessons;
{module.lessons.map((lesson, lessonIdx) => {
const key = `${slugify(module.title)}__${slugify(lesson)}`;
const isCompleted = done.includes(key);
return ( return (
<button <div key={moduleIdx} className="rounded-md">
key={key} <button
onClick={() => { onClick={() => toggleModule(moduleIdx)}
setActiveModuleIndex(moduleIdx); className={cn(
setActiveLessonIndex(lessonIdx); 'flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium',
// Expand only this module in the sidebar activeModuleIndex === moduleIdx
setExpandedModules((prev) => { ? 'bg-gray-100 text-gray-900'
const newState: Record<number, boolean> = {}; : 'text-gray-700 hover:bg-gray-50',
// Set all modules to collapsed )}
course.modules.forEach((_, idx) => { >
newState[idx] = false; <div className="flex min-w-0 items-start pr-2">
}); <CircularProgress
// Expand only the current module percentage={percentage}
newState[moduleIdx] = true; isVisible={!isModuleCompleted}
return newState; isActive={isActive}
}); isLoading={isLoading}
// Ensure sidebar is visible on mobile >
setSidebarOpen(true); <span
setViewMode('module');
}}
className={cn( className={cn(
'flex w-full items-start rounded-md px-3 py-2 text-left text-sm', 'flex size-[21px] flex-shrink-0 items-center justify-center rounded-full bg-gray-400/70 text-xs font-semibold text-white',
activeModuleIndex === moduleIdx && {
activeLessonIndex === lessonIdx 'bg-black': isActive,
? 'bg-gray-800 text-white' 'bg-green-600': isModuleCompleted,
: 'text-gray-600 hover:bg-gray-50', },
)} )}
> >
{isCompleted ? ( {!isModuleCompleted && moduleIdx + 1}
<CheckIcon additionalClasses="size-3.5 relative top-[2px] mr-2 flex-shrink-0 text-green-500" /> {isModuleCompleted && (
) : ( <Check className="h-3 w-3 stroke-[3] text-white" />
<span className="relative top-[2px] mr-2 flex-shrink-0 text-xs">
{lessonIdx + 1}.
</span>
)} )}
<span className="break-words"> </span>
{lesson?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} </CircularProgress>
</span> <span className="ml-2 break-words">
</button> {module.title?.replace(/^Module\s*?\d+[\.:]\s*/, '')}
); </span>
})} </div>
</div> {expandedModules[moduleIdx] ? (
)} <ChevronDownIcon size={16} className="flex-shrink-0" />
</div> ) : (
))} <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) => {
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;
});
// 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>
);
})}
</nav> </nav>
); );
} }

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