parent
f0d208f050
commit
1b0f219641
7 changed files with 491 additions and 105 deletions
@ -0,0 +1,127 @@ |
||||
import { RegenerateOutline } from './RegenerateOutline'; |
||||
import { cn } from '../../lib/classname'; |
||||
import type { AiCourse } from '../../lib/ai'; |
||||
import { slugify } from '../../lib/slugger'; |
||||
import { CheckIcon } from '../ReactIcons/CheckIcon'; |
||||
import type { Dispatch, SetStateAction } from 'react'; |
||||
import { Loader2Icon } from 'lucide-react'; |
||||
import type { AICourseViewMode } from './AICourseContent'; |
||||
|
||||
type AICourseOutlineViewProps = { |
||||
course: AiCourse; |
||||
isLoading: boolean; |
||||
onRegenerateOutline: (prompt?: string) => void; |
||||
setActiveModuleIndex: (index: number) => void; |
||||
setActiveLessonIndex: (index: number) => void; |
||||
setSidebarOpen: (open: boolean) => void; |
||||
setViewMode: (mode: AICourseViewMode) => void; |
||||
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>; |
||||
}; |
||||
|
||||
export function AICourseOutlineView(props: AICourseOutlineViewProps) { |
||||
const { |
||||
course, |
||||
isLoading, |
||||
onRegenerateOutline, |
||||
setActiveModuleIndex, |
||||
setActiveLessonIndex, |
||||
setSidebarOpen, |
||||
setViewMode, |
||||
setExpandedModules, |
||||
} = props; |
||||
|
||||
const aiCourseProgress = course.done || []; |
||||
|
||||
return ( |
||||
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl"> |
||||
<div |
||||
className={cn( |
||||
'relative mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden', |
||||
isLoading && 'striped-loader', |
||||
)} |
||||
> |
||||
<div> |
||||
<h2 className="mb-1 text-balance text-2xl font-bold max-lg:text-lg max-lg:leading-tight"> |
||||
{course.title || 'Loading course ..'} |
||||
</h2> |
||||
<p className="text-sm capitalize text-gray-500"> |
||||
{course.title ? course.difficulty : 'Please wait ..'} |
||||
</p> |
||||
</div> |
||||
|
||||
{!isLoading && ( |
||||
<RegenerateOutline onRegenerateOutline={onRegenerateOutline} /> |
||||
)} |
||||
</div> |
||||
{course.title ? ( |
||||
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4"> |
||||
{course.modules.map((courseModule, moduleIdx) => { |
||||
return ( |
||||
<div |
||||
key={moduleIdx} |
||||
className="mb-5 pb-4 last:border-0 last:pb-0 max-lg:mb-2" |
||||
> |
||||
<h2 className="mb-4 text-xl font-bold text-gray-800 max-lg:mb-2 max-lg:text-lg max-lg:leading-tight"> |
||||
{courseModule.title} |
||||
</h2> |
||||
<div className="divide-y divide-gray-100"> |
||||
{courseModule.lessons.map((lesson, lessonIdx) => { |
||||
const key = `${slugify(String(moduleIdx))}-${slugify(String(lessonIdx))}`; |
||||
const isCompleted = aiCourseProgress.includes(key); |
||||
|
||||
return ( |
||||
<div |
||||
key={key} |
||||
className="flex cursor-pointer items-center gap-2 px-2 py-2.5 transition-colors hover:bg-gray-100 max-lg:px-0 max-lg:py-1.5" |
||||
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(false); |
||||
setViewMode('module'); |
||||
}} |
||||
> |
||||
{!isCompleted && ( |
||||
<span |
||||
className={cn( |
||||
'flex size-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-sm font-medium text-gray-800 max-lg:size-5 max-lg:text-xs', |
||||
)} |
||||
> |
||||
{lessonIdx + 1} |
||||
</span> |
||||
)} |
||||
|
||||
{isCompleted && ( |
||||
<CheckIcon additionalClasses="size-6 flex-shrink-0 text-green-500" /> |
||||
)} |
||||
|
||||
<p className="flex-1 truncate text-base text-gray-800 max-lg:text-sm"> |
||||
{lesson.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} |
||||
</p> |
||||
<span className="text-sm font-medium text-gray-700 max-lg:hidden"> |
||||
{isCompleted ? 'View' : 'Start'} → |
||||
</span> |
||||
</div> |
||||
); |
||||
})} |
||||
</div> |
||||
</div> |
||||
); |
||||
})} |
||||
</div> |
||||
) : ( |
||||
<div className="flex h-64 items-center justify-center"> |
||||
<Loader2Icon size={36} className="animate-spin text-gray-300" /> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,202 @@ |
||||
import '../GenerateRoadmap/GenerateRoadmap.css'; |
||||
import { renderFlowJSON } from '../../../editor/renderer/renderer'; |
||||
import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator'; |
||||
import { |
||||
generateAICourseRoadmapStructure, |
||||
readStream, |
||||
type ResultItem, |
||||
} from '../../lib/ai'; |
||||
import { |
||||
useCallback, |
||||
useEffect, |
||||
useRef, |
||||
useState, |
||||
type Dispatch, |
||||
type SetStateAction, |
||||
type MouseEvent, |
||||
} from 'react'; |
||||
import type { AICourseViewMode } from './AICourseContent'; |
||||
import { replaceChildren } from '../../lib/dom'; |
||||
import { Loader2Icon } from 'lucide-react'; |
||||
import { ErrorIcon } from '../ReactIcons/ErrorIcon'; |
||||
|
||||
export type AICourseRoadmapViewProps = { |
||||
courseSlug: string; |
||||
setActiveModuleIndex: (index: number) => void; |
||||
setActiveLessonIndex: (index: number) => void; |
||||
setViewMode: (mode: AICourseViewMode) => void; |
||||
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>; |
||||
}; |
||||
|
||||
export function AICourseRoadmapView(props: AICourseRoadmapViewProps) { |
||||
const { |
||||
courseSlug, |
||||
setActiveModuleIndex, |
||||
setActiveLessonIndex, |
||||
setViewMode, |
||||
setExpandedModules, |
||||
} = props; |
||||
|
||||
const containerEl = useRef<HTMLDivElement>(null); |
||||
const [roadmapStructure, setRoadmapStructure] = useState<ResultItem[]>([]); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [isGenerating, setIsGenerating] = useState(false); |
||||
const [error, setError] = useState<string | null>(null); |
||||
|
||||
const generateAICourseRoadmap = async (courseSlug: string) => { |
||||
try { |
||||
const response = await fetch( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course-roadmap/${courseSlug}`, |
||||
{ |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
}, |
||||
credentials: 'include', |
||||
}, |
||||
); |
||||
|
||||
if (!response.ok) { |
||||
const data = await response.json(); |
||||
console.error( |
||||
'Error generating course roadmap:', |
||||
data?.message || 'Something went wrong', |
||||
); |
||||
setError(data?.message || 'Something went wrong'); |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
const reader = response.body?.getReader(); |
||||
if (!reader) { |
||||
console.error('Failed to get reader from response'); |
||||
setError('Something went wrong'); |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(false); |
||||
setIsGenerating(true); |
||||
await readStream(reader, { |
||||
onStream: async (result) => { |
||||
const roadmap = generateAICourseRoadmapStructure(result); |
||||
const { nodes, edges } = generateAIRoadmapFromText(roadmap); |
||||
const svg = await renderFlowJSON({ nodes, edges }); |
||||
replaceChildren(containerEl.current!, svg); |
||||
}, |
||||
onStreamEnd: async (result) => { |
||||
const roadmap = generateAICourseRoadmapStructure(result); |
||||
const { nodes, edges } = generateAIRoadmapFromText(roadmap); |
||||
const svg = await renderFlowJSON({ nodes, edges }); |
||||
replaceChildren(containerEl.current!, svg); |
||||
setRoadmapStructure(roadmap); |
||||
setIsGenerating(false); |
||||
}, |
||||
}); |
||||
} catch (error) { |
||||
console.error('Error generating course roadmap:', error); |
||||
setError('Something went wrong'); |
||||
setIsLoading(false); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
if (!courseSlug) { |
||||
return; |
||||
} |
||||
|
||||
generateAICourseRoadmap(courseSlug); |
||||
}, []); |
||||
|
||||
const handleNodeClick = useCallback( |
||||
(e: MouseEvent<HTMLDivElement, unknown>) => { |
||||
if (isLoading || isGenerating) { |
||||
return; |
||||
} |
||||
|
||||
const target = e.target as SVGElement; |
||||
const targetGroup = (target?.closest('g') as SVGElement) || {}; |
||||
|
||||
const nodeId = targetGroup?.dataset?.nodeId; |
||||
const nodeType = targetGroup?.dataset?.type; |
||||
const nodeTitle = targetGroup?.dataset?.title; |
||||
const parentTitle = targetGroup?.dataset?.parentTitle; |
||||
if (!nodeId || !nodeType) { |
||||
return null; |
||||
} |
||||
|
||||
const filteredRoadmapStructure = roadmapStructure.filter( |
||||
(module) => module.type !== 'title', |
||||
); |
||||
|
||||
const moduleIndex = filteredRoadmapStructure.findIndex( |
||||
(module) => module.label === parentTitle, |
||||
); |
||||
|
||||
const module = filteredRoadmapStructure[moduleIndex]; |
||||
if (module?.type !== 'topic') { |
||||
return; |
||||
} |
||||
|
||||
const topicIndex = module.children?.findIndex( |
||||
(topic) => topic.label === nodeTitle, |
||||
); |
||||
|
||||
if (topicIndex === undefined) { |
||||
return; |
||||
} |
||||
|
||||
const topic = module.children?.[topicIndex]; |
||||
if (topic?.type !== 'subtopic') { |
||||
return; |
||||
} |
||||
|
||||
setExpandedModules((prev) => { |
||||
const newState: Record<number, boolean> = {}; |
||||
roadmapStructure.forEach((_, idx) => { |
||||
newState[idx] = false; |
||||
}); |
||||
newState[moduleIndex] = true; |
||||
return newState; |
||||
}); |
||||
setActiveModuleIndex(moduleIndex); |
||||
setActiveLessonIndex(topicIndex); |
||||
setViewMode('module'); |
||||
}, |
||||
[ |
||||
isLoading, |
||||
roadmapStructure, |
||||
setExpandedModules, |
||||
setActiveModuleIndex, |
||||
setActiveLessonIndex, |
||||
setViewMode, |
||||
], |
||||
); |
||||
|
||||
return ( |
||||
<div className="relative mx-auto min-h-[200px] rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl"> |
||||
{isLoading && ( |
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center"> |
||||
<Loader2Icon className="h-10 w-10 animate-spin stroke-[3px]" /> |
||||
</div> |
||||
)} |
||||
|
||||
{error && !isLoading && !isGenerating && ( |
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center"> |
||||
<ErrorIcon additionalClasses="h-10 w-10" /> |
||||
<p className="text-sm text-gray-500"> |
||||
{error || 'Something went wrong'} |
||||
</p> |
||||
</div> |
||||
)} |
||||
|
||||
<div |
||||
id={'resource-svg-wrap'} |
||||
ref={containerEl} |
||||
onClick={handleNodeClick} |
||||
className="px-4 pb-2" |
||||
></div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,39 @@ |
||||
import { cn } from '../../lib/classname'; |
||||
import type { AICourseViewMode } from './AICourseContent'; |
||||
|
||||
type AIRoadmapViewSwitchProps = { |
||||
viewMode: AICourseViewMode; |
||||
setViewMode: (mode: AICourseViewMode) => void; |
||||
isLoading: boolean; |
||||
}; |
||||
|
||||
export function AIRoadmapViewSwitch(props: AIRoadmapViewSwitchProps) { |
||||
const { viewMode, setViewMode, isLoading } = props; |
||||
|
||||
return ( |
||||
<div className="sticky top-0 z-10 mx-auto mb-5 flex justify-center"> |
||||
<div className="grid min-w-[200px] grid-cols-2 gap-0.5 rounded-xl border border-gray-200 bg-white p-0.5 shadow-sm"> |
||||
<button |
||||
className={cn( |
||||
'rounded-lg px-2 py-1 text-sm font-medium disabled:cursor-not-allowed', |
||||
viewMode === 'outline' && 'bg-gray-100 text-gray-800', |
||||
)} |
||||
onClick={() => setViewMode('outline')} |
||||
disabled={isLoading} |
||||
> |
||||
Outline |
||||
</button> |
||||
<button |
||||
className={cn( |
||||
'rounded-lg px-2 py-1 text-sm font-medium disabled:cursor-not-allowed', |
||||
viewMode === 'roadmap' && 'bg-gray-100 text-gray-800', |
||||
)} |
||||
onClick={() => setViewMode('roadmap')} |
||||
disabled={isLoading} |
||||
> |
||||
Roadmap |
||||
</button> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
Loading…
Reference in new issue