computer-scienceangular-roadmapbackend-roadmapblockchain-roadmapdba-roadmapdeveloper-roadmapdevops-roadmapfrontend-roadmapgo-roadmaphactoberfestjava-roadmapjavascript-roadmapnodejs-roadmappython-roadmapqa-roadmapreact-roadmaproadmapstudy-planvue-roadmapweb3-roadmap
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
185 lines
6.6 KiB
185 lines
6.6 KiB
import { type Dispatch, type SetStateAction, useState } from 'react'; |
|
import type { AiCourse } from '../../lib/ai'; |
|
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; |
|
courseSlug?: string; |
|
activeModuleIndex: number | undefined; |
|
setActiveModuleIndex: (index: number) => void; |
|
activeLessonIndex: number | undefined; |
|
setActiveLessonIndex: (index: number) => void; |
|
|
|
setSidebarOpen: (open: boolean) => void; |
|
|
|
viewMode: 'module' | 'full'; |
|
setViewMode: (mode: 'module' | 'full') => void; |
|
|
|
expandedModules: Record<number, boolean>; |
|
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>; |
|
|
|
isLoading: boolean; |
|
}; |
|
|
|
export function AICourseModuleList(props: AICourseModuleListProps) { |
|
const { |
|
course, |
|
courseSlug, |
|
activeModuleIndex, |
|
setActiveModuleIndex, |
|
activeLessonIndex, |
|
setActiveLessonIndex, |
|
setSidebarOpen, |
|
setViewMode, |
|
expandedModules, |
|
setExpandedModules, |
|
|
|
isLoading, |
|
} = props; |
|
|
|
const { data: aiCourseProgress } = useQuery( |
|
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }), |
|
queryClient, |
|
); |
|
|
|
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<number, boolean> = {}; |
|
// Set all modules to collapsed |
|
course.modules.forEach((_, idx) => { |
|
newState[idx] = false; |
|
}); |
|
// Expand only the clicked module |
|
newState[index] = true; |
|
return newState; |
|
}); |
|
}; |
|
|
|
const { done = [] } = aiCourseProgress || {}; |
|
|
|
return ( |
|
<nav className="space-y-1 py-2 px-2"> |
|
{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)} |
|
className={cn( |
|
'flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium', |
|
activeModuleIndex === moduleIdx |
|
? 'bg-gray-100 text-gray-900' |
|
: 'text-gray-700 hover:bg-gray-50', |
|
)} |
|
> |
|
<div className="flex min-w-0 items-center pr-2"> |
|
<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> |
|
</CircularProgress> |
|
<span className="ml-2 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 */} |
|
{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); |
|
setExpandedModules((prev) => { |
|
const newState: Record<number, boolean> = {}; |
|
course.modules.forEach((_, idx) => { |
|
newState[idx] = false; |
|
}); |
|
newState[moduleIdx] = true; |
|
return newState; |
|
}); |
|
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> |
|
); |
|
}
|
|
|