1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-10-04 21:03:23 +02:00

feat: course ai roadmap (#8352)

* feat: course ai roadmap

* wip

* fix: error

* refactor: remove open ai key

* wip: view switch

* feat: add roadmap progress

* fix: simplify module

* wip

* Update outline generation

* Update course limits popup

* fix: module done

* Updates to AI usage

* UI and error handling

* Map and outline view to share header

* Outline switcher

* Responsive AI generation

* Update header for course

* Roadmap switch

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
Arik Chakma
2025-03-26 04:21:39 +06:00
committed by GitHub
parent 80a4ebbb3d
commit 85202507e6
17 changed files with 785 additions and 499 deletions

View File

@@ -0,0 +1,252 @@
import '../GenerateRoadmap/GenerateRoadmap.css';
import { renderFlowJSON } from '../../../editor/renderer/renderer';
import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator';
import {
generateAICourseRoadmapStructure,
readAIRoadmapStream,
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 { Frown, Loader2Icon } from 'lucide-react';
import { renderTopicProgress } from '../../lib/resource-progress';
import { queryClient } from '../../stores/query-client';
import { useQuery } from '@tanstack/react-query';
import { billingDetailsOptions } from '../../queries/billing';
import { AICourseOutlineHeader } from './AICourseOutlineHeader';
import type { AiCourse } from '../../lib/ai';
export type AICourseRoadmapViewProps = {
done: string[];
courseSlug: string;
course: AiCourse;
isLoading: boolean;
onRegenerateOutline: (prompt?: string) => void;
setActiveModuleIndex: (index: number) => void;
setActiveLessonIndex: (index: number) => void;
setViewMode: (mode: AICourseViewMode) => void;
onUpgradeClick: () => void;
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>;
viewMode: AICourseViewMode;
};
export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
const {
done = [],
courseSlug,
course,
isLoading,
onRegenerateOutline,
setActiveModuleIndex,
setActiveLessonIndex,
setViewMode,
setExpandedModules,
onUpgradeClick,
viewMode,
} = props;
const containerEl = useRef<HTMLDivElement>(null);
const [roadmapStructure, setRoadmapStructure] = useState<ResultItem[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isPaidUser = userBillingDetails?.status === 'active';
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');
setIsGenerating(false);
return;
}
const reader = response.body?.getReader();
if (!reader) {
console.error('Failed to get reader from response');
setError('Something went wrong');
setIsGenerating(false);
return;
}
setIsGenerating(true);
await readAIRoadmapStream(reader, {
onStream: async (result) => {
const roadmap = generateAICourseRoadmapStructure(result, true);
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
const svg = await renderFlowJSON({ nodes, edges });
replaceChildren(containerEl.current!, svg);
},
onStreamEnd: async (result) => {
const roadmap = generateAICourseRoadmapStructure(result, true);
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
const svg = await renderFlowJSON({ nodes, edges });
replaceChildren(containerEl.current!, svg);
setRoadmapStructure(roadmap);
setIsGenerating(false);
done.forEach((id) => {
renderTopicProgress(id, 'done');
});
const modules = roadmap.filter((item) => item.type === 'topic');
for (const module of modules) {
const moduleId = module.id;
const isAllLessonsDone =
module?.children?.every((child) => done.includes(child.id)) ??
false;
if (isAllLessonsDone) {
renderTopicProgress(moduleId, 'done');
}
}
},
});
} catch (error) {
console.error('Error generating course roadmap:', error);
setError('Something went wrong');
setIsGenerating(false);
}
};
useEffect(() => {
if (!courseSlug) {
return;
}
generateAICourseRoadmap(courseSlug);
}, []);
const handleNodeClick = useCallback(
(e: MouseEvent<HTMLDivElement, unknown>) => {
if (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;
if (!nodeId || !nodeType) {
return null;
}
if (nodeType === 'topic') {
const topicIndex = roadmapStructure
.filter((item) => item.type === 'topic')
.findIndex((item) => item.id === nodeId);
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
roadmapStructure.forEach((_, idx) => {
newState[idx] = false;
});
newState[topicIndex] = true;
return newState;
});
setActiveModuleIndex(topicIndex);
setActiveLessonIndex(0);
setViewMode('module');
return;
}
if (nodeType !== 'subtopic') {
return null;
}
const [moduleIndex, topicIndex] = nodeId.split('-').map(Number);
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
roadmapStructure.forEach((_, idx) => {
newState[idx] = false;
});
newState[moduleIndex] = true;
return newState;
});
setActiveModuleIndex(moduleIndex);
setActiveLessonIndex(topicIndex);
setViewMode('module');
},
[
roadmapStructure,
setExpandedModules,
setActiveModuleIndex,
setActiveLessonIndex,
setViewMode,
],
);
return (
<div className="relative mx-auto min-h-[500px] rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-5xl">
<AICourseOutlineHeader
course={course}
isLoading={isLoading}
onRegenerateOutline={(prompt) => {
setViewMode('outline');
onRegenerateOutline(prompt);
}}
viewMode={viewMode}
setViewMode={setViewMode}
/>
{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 && !isGenerating && (
<div className="absolute inset-0 flex h-full w-full flex-col items-center justify-center">
<Frown className="size-20 text-red-500" />
<p className="mx-auto mt-5 max-w-[250px] text-balance text-center text-base text-red-500">
{error || 'Something went wrong'}
</p>
{!isPaidUser && (error || '')?.includes('limit') && (
<button
onClick={onUpgradeClick}
className="mt-5 rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700"
>
Upgrade Account
</button>
)}
</div>
)}
<div
id={'resource-svg-wrap'}
ref={containerEl}
onClick={handleNodeClick}
className="px-4 pb-2"
></div>
</div>
);
}