mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-25 08:35:42 +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:
@@ -3,24 +3,22 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
CircleOff,
|
CircleOff,
|
||||||
Loader2,
|
|
||||||
Menu,
|
Menu,
|
||||||
Play,
|
|
||||||
X,
|
X,
|
||||||
|
Map,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { type AiCourse } from '../../lib/ai';
|
import { type AiCourse } from '../../lib/ai';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { slugify } from '../../lib/slugger';
|
|
||||||
import { useIsPaidUser } from '../../queries/billing';
|
import { useIsPaidUser } from '../../queries/billing';
|
||||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
|
||||||
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
||||||
import { AICourseLesson } from './AICourseLesson';
|
import { AICourseLesson } from './AICourseLesson';
|
||||||
import { AICourseLimit } from './AICourseLimit';
|
import { AICourseLimit } from './AICourseLimit';
|
||||||
import { AICourseSidebarModuleList } from './AICourseSidebarModuleList';
|
import { AICourseSidebarModuleList } from './AICourseSidebarModuleList';
|
||||||
import { AILimitsPopup } from './AILimitsPopup';
|
import { AILimitsPopup } from './AILimitsPopup';
|
||||||
import { RegenerateOutline } from './RegenerateOutline';
|
import { AICourseOutlineView } from './AICourseOutlineView';
|
||||||
|
import { AICourseRoadmapView } from './AICourseRoadmapView';
|
||||||
|
|
||||||
type AICourseContentProps = {
|
type AICourseContentProps = {
|
||||||
courseSlug?: string;
|
courseSlug?: string;
|
||||||
@@ -30,6 +28,8 @@ type AICourseContentProps = {
|
|||||||
onRegenerateOutline: (prompt?: string) => void;
|
onRegenerateOutline: (prompt?: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AICourseViewMode = 'module' | 'outline' | 'roadmap';
|
||||||
|
|
||||||
export function AICourseContent(props: AICourseContentProps) {
|
export function AICourseContent(props: AICourseContentProps) {
|
||||||
const { course, courseSlug, isLoading, error, onRegenerateOutline } = props;
|
const { course, courseSlug, isLoading, error, onRegenerateOutline } = props;
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
const [activeModuleIndex, setActiveModuleIndex] = useState(0);
|
const [activeModuleIndex, setActiveModuleIndex] = useState(0);
|
||||||
const [activeLessonIndex, setActiveLessonIndex] = useState(0);
|
const [activeLessonIndex, setActiveLessonIndex] = useState(0);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<'module' | 'outline'>('outline');
|
const [viewMode, setViewMode] = useState<AICourseViewMode>('outline');
|
||||||
|
|
||||||
const { isPaidUser } = useIsPaidUser();
|
const { isPaidUser } = useIsPaidUser();
|
||||||
|
|
||||||
@@ -257,6 +257,7 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
<span className="font-medium">{totalModules} modules</span>
|
<span className="font-medium">{totalModules} modules</span>
|
||||||
<span className="text-gray-400">•</span>
|
<span className="text-gray-400">•</span>
|
||||||
<span className="font-medium">{totalCourseLessons} lessons</span>
|
<span className="font-medium">{totalCourseLessons} lessons</span>
|
||||||
|
|
||||||
{viewMode === 'module' && (
|
{viewMode === 'module' && (
|
||||||
<span className="flex flex-row items-center gap-1 lg:hidden">
|
<span className="flex flex-row items-center gap-1 lg:hidden">
|
||||||
<span className="text-gray-400">•</span>
|
<span className="text-gray-400">•</span>
|
||||||
@@ -271,6 +272,7 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{finishedPercentage > 0 && (
|
{finishedPercentage > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-400">•</span>
|
<span className="text-gray-400">•</span>
|
||||||
@@ -289,19 +291,6 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
onShowLimits={() => setShowAILimitsPopup(true)}
|
onShowLimits={() => setShowAILimitsPopup(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'module' && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setExpandedModules({});
|
|
||||||
setViewMode('outline');
|
|
||||||
}}
|
|
||||||
className="flex flex-shrink-0 items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 max-lg:hidden"
|
|
||||||
>
|
|
||||||
<BookOpenCheck size={18} className="mr-2" />
|
|
||||||
View Course Outline
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -336,36 +325,38 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
)}
|
)}
|
||||||
></span>
|
></span>
|
||||||
|
|
||||||
{viewMode !== 'outline' && (
|
<div className="flex gap-0 rounded-md bg-white p-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExpandedModules({});
|
setExpandedModules({});
|
||||||
setViewMode('outline');
|
setViewMode('outline');
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1 rounded-md bg-gray-200 px-2.5 py-1.5 text-xs transition-colors hover:bg-gray-300"
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||||
|
viewMode === 'outline'
|
||||||
|
? 'bg-gray-200 text-gray-900'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<BookOpenCheck size={14} />
|
<BookOpenCheck size={14} />
|
||||||
View Outline
|
Outline
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
{viewMode === 'outline' && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setExpandedModules({
|
setExpandedModules({});
|
||||||
...expandedModules,
|
setViewMode('roadmap');
|
||||||
0: true,
|
|
||||||
});
|
|
||||||
setActiveModuleIndex(0);
|
|
||||||
setActiveLessonIndex(0);
|
|
||||||
setViewMode('module');
|
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1 rounded-md bg-gray-200 px-2.5 py-1.5 text-xs transition-colors hover:bg-gray-300"
|
className={cn(
|
||||||
>
|
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||||
<Play size={14} />
|
viewMode === 'roadmap'
|
||||||
Start Course
|
? 'bg-gray-200 text-gray-900'
|
||||||
</button>
|
: 'text-gray-600 hover:bg-gray-50',
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Map size={14} />
|
||||||
|
Map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -399,9 +390,10 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
|
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 overflow-y-auto p-6 transition-all duration-200 ease-in-out max-lg:p-3',
|
'flex-1 overflow-y-scroll p-6 transition-all duration-200 ease-in-out max-lg:p-3',
|
||||||
sidebarOpen ? 'lg:ml-0' : '',
|
sidebarOpen ? 'lg:ml-0' : '',
|
||||||
)}
|
)}
|
||||||
|
key={`${courseSlug}-${viewMode}`}
|
||||||
>
|
>
|
||||||
{viewMode === 'module' && (
|
{viewMode === 'module' && (
|
||||||
<AICourseLesson
|
<AICourseLesson
|
||||||
@@ -421,103 +413,37 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{viewMode === 'outline' && (
|
{viewMode === 'outline' && (
|
||||||
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl">
|
<AICourseOutlineView
|
||||||
<div
|
course={course}
|
||||||
className={cn(
|
isLoading={isLoading}
|
||||||
'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}
|
onRegenerateOutline={onRegenerateOutline}
|
||||||
|
setActiveModuleIndex={setActiveModuleIndex}
|
||||||
|
setActiveLessonIndex={setActiveLessonIndex}
|
||||||
|
setSidebarOpen={setSidebarOpen}
|
||||||
|
setViewMode={setViewMode}
|
||||||
|
setExpandedModules={setExpandedModules}
|
||||||
|
viewMode={viewMode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</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 (
|
{viewMode === 'roadmap' && !isLoading && (
|
||||||
<div
|
<AICourseRoadmapView
|
||||||
key={key}
|
done={course.done}
|
||||||
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"
|
courseSlug={courseSlug!}
|
||||||
onClick={() => {
|
course={course}
|
||||||
setActiveModuleIndex(moduleIdx);
|
isLoading={isLoading}
|
||||||
setActiveLessonIndex(lessonIdx);
|
onRegenerateOutline={onRegenerateOutline}
|
||||||
setExpandedModules((prev) => {
|
setActiveModuleIndex={setActiveModuleIndex}
|
||||||
const newState: Record<number, boolean> =
|
setActiveLessonIndex={setActiveLessonIndex}
|
||||||
{};
|
setViewMode={setViewMode}
|
||||||
course.modules.forEach((_, idx) => {
|
setExpandedModules={setExpandedModules}
|
||||||
newState[idx] = false;
|
onUpgradeClick={() => setShowUpgradeModal(true)}
|
||||||
});
|
viewMode={viewMode}
|
||||||
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 && (
|
<div className="mx-auto mb-10 mt-5 text-center text-sm text-gray-400">
|
||||||
<CheckIcon additionalClasses="size-6 flex-shrink-0 text-green-500" />
|
AI can make mistakes, check imporant info.
|
||||||
)}
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<Loader2 size={36} className="animate-spin text-gray-300" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mb-10 mt-5 mx-auto text-center text-sm text-gray-400">
|
|
||||||
AI can make mistakes, check important info.
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -2,8 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Bot,
|
Bot,
|
||||||
Code,
|
Hammer,
|
||||||
Globe, Hammer,
|
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
Send,
|
Send,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
} from '../../lib/markdown';
|
} from '../../lib/markdown';
|
||||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
|
import { billingDetailsOptions } from '../../queries/billing';
|
||||||
|
|
||||||
export type AllowedAIChatRole = 'user' | 'assistant';
|
export type AllowedAIChatRole = 'user' | 'assistant';
|
||||||
export type AIChatHistoryType = {
|
export type AIChatHistoryType = {
|
||||||
@@ -70,7 +70,11 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
|
|||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||||
|
useQuery(billingDetailsOptions(), queryClient);
|
||||||
|
|
||||||
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||||
|
const isPaidUser = userBillingDetails?.status === 'active';
|
||||||
|
|
||||||
const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => {
|
const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -247,7 +251,11 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
|
|||||||
{isLimitExceeded && (
|
{isLimitExceeded && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white">
|
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white">
|
||||||
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} />
|
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} />
|
||||||
<p className="cursor-not-allowed">Limit reached for today</p>
|
<p className="cursor-not-allowed">
|
||||||
|
Limit reached for today
|
||||||
|
{isPaidUser ? '. Please wait until tomorrow.' : ''}
|
||||||
|
</p>
|
||||||
|
{!isPaidUser && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpgradeClick();
|
onUpgradeClick();
|
||||||
@@ -256,6 +264,7 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
|
|||||||
>
|
>
|
||||||
Upgrade for more
|
Upgrade for more
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
|
@@ -290,7 +290,7 @@ export function AICourseLesson(props: AICourseLessonProps) {
|
|||||||
<p className="my-3 text-red-600">
|
<p className="my-3 text-red-600">
|
||||||
You have reached the AI usage limit for today.
|
You have reached the AI usage limit for today.
|
||||||
{!isPaidUser && <>Please upgrade your account to continue.</>}
|
{!isPaidUser && <>Please upgrade your account to continue.</>}
|
||||||
{isPaidUser && <>Please wait until tomorrow to continue.</>}
|
{isPaidUser && <> Please wait until tomorrow to continue.</>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!isPaidUser && (
|
{!isPaidUser && (
|
||||||
|
@@ -32,13 +32,11 @@ export function AICourseLimit(props: AICourseLimitProps) {
|
|||||||
const totalPercentage = getPercentage(used, limit);
|
const totalPercentage = getPercentage(used, limit);
|
||||||
|
|
||||||
// has consumed 85% of the limit
|
// has consumed 85% of the limit
|
||||||
const isNearLimit = used >= limit * 0.85;
|
|
||||||
const isPaidUser = userBillingDetails.status === 'active';
|
const isPaidUser = userBillingDetails.status === 'active';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isPaidUser ||
|
{!isPaidUser && (
|
||||||
(isNearLimit && (
|
|
||||||
<button
|
<button
|
||||||
className="mr-1 flex items-center gap-1 text-sm font-medium underline underline-offset-2 lg:hidden"
|
className="mr-1 flex items-center gap-1 text-sm font-medium underline underline-offset-2 lg:hidden"
|
||||||
onClick={() => onShowLimits()}
|
onClick={() => onShowLimits()}
|
||||||
@@ -46,9 +44,9 @@ export function AICourseLimit(props: AICourseLimitProps) {
|
|||||||
<Info className="size-4" />
|
<Info className="size-4" />
|
||||||
{totalPercentage}% limit used
|
{totalPercentage}% limit used
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
{(!isPaidUser || isNearLimit) && (
|
{!isPaidUser && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onShowLimits();
|
onShowLimits();
|
||||||
|
80
src/components/GenerateCourse/AICourseOutlineHeader.tsx
Normal file
80
src/components/GenerateCourse/AICourseOutlineHeader.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { cn } from '../../lib/classname';
|
||||||
|
import type { AiCourse } from '../../lib/ai';
|
||||||
|
import { RegenerateOutline } from './RegenerateOutline';
|
||||||
|
import type { AICourseViewMode } from './AICourseContent';
|
||||||
|
import { BookOpenCheck, Signpost } from 'lucide-react';
|
||||||
|
|
||||||
|
type AICourseOutlineHeaderProps = {
|
||||||
|
course: AiCourse;
|
||||||
|
isLoading: boolean;
|
||||||
|
onRegenerateOutline: (prompt?: string) => void;
|
||||||
|
viewMode: AICourseViewMode;
|
||||||
|
setViewMode: (mode: AICourseViewMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
|
||||||
|
const { course, isLoading, onRegenerateOutline, viewMode, setViewMode } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:p-3',
|
||||||
|
isLoading && 'striped-loader',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="max-lg:hidden">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="absolute right-3 top-3 flex gap-2 max-lg:relative max-lg:right-0 max-lg:top-0 max-lg:w-full max-lg:flex-row-reverse max-lg:justify-between">
|
||||||
|
{!isLoading && (
|
||||||
|
<>
|
||||||
|
<RegenerateOutline onRegenerateOutline={onRegenerateOutline} />
|
||||||
|
<div className="mr-1 flex rounded-lg border border-gray-200 bg-white p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('outline')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors',
|
||||||
|
viewMode === 'outline'
|
||||||
|
? 'bg-gray-200 text-gray-900'
|
||||||
|
: 'text-gray-500 hover:text-gray-900',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BookOpenCheck
|
||||||
|
className={cn(
|
||||||
|
'size-4',
|
||||||
|
viewMode === 'outline' && 'text-gray-900',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>Outline</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('roadmap')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors',
|
||||||
|
viewMode === 'roadmap'
|
||||||
|
? 'bg-gray-200 text-gray-900'
|
||||||
|
: 'text-gray-500 hover:text-gray-900',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Signpost
|
||||||
|
className={cn(
|
||||||
|
'size-4',
|
||||||
|
viewMode === 'roadmap' && 'text-gray-900',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>Map</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
117
src/components/GenerateCourse/AICourseOutlineView.tsx
Normal file
117
src/components/GenerateCourse/AICourseOutlineView.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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';
|
||||||
|
import { AICourseOutlineHeader } from './AICourseOutlineHeader';
|
||||||
|
|
||||||
|
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>>>;
|
||||||
|
viewMode: AICourseViewMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AICourseOutlineView(props: AICourseOutlineViewProps) {
|
||||||
|
const {
|
||||||
|
course,
|
||||||
|
isLoading,
|
||||||
|
onRegenerateOutline,
|
||||||
|
setActiveModuleIndex,
|
||||||
|
setActiveLessonIndex,
|
||||||
|
setSidebarOpen,
|
||||||
|
setViewMode,
|
||||||
|
setExpandedModules,
|
||||||
|
viewMode,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const aiCourseProgress = course.done || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-5xl">
|
||||||
|
<AICourseOutlineHeader
|
||||||
|
course={course}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onRegenerateOutline={onRegenerateOutline}
|
||||||
|
viewMode={viewMode}
|
||||||
|
setViewMode={setViewMode}
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
252
src/components/GenerateCourse/AICourseRoadmapView.tsx
Normal file
252
src/components/GenerateCourse/AICourseRoadmapView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -5,6 +5,7 @@ import { cn } from '../../lib/classname';
|
|||||||
import { slugify } from '../../lib/slugger';
|
import { slugify } from '../../lib/slugger';
|
||||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||||
import { CircularProgress } from './CircularProgress';
|
import { CircularProgress } from './CircularProgress';
|
||||||
|
import type { AICourseViewMode } from './AICourseContent';
|
||||||
|
|
||||||
type AICourseModuleListProps = {
|
type AICourseModuleListProps = {
|
||||||
course: AiCourse;
|
course: AiCourse;
|
||||||
@@ -16,8 +17,8 @@ type AICourseModuleListProps = {
|
|||||||
|
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
|
||||||
viewMode: 'module' | 'outline';
|
viewMode: AICourseViewMode;
|
||||||
setViewMode: (mode: 'module' | 'outline') => void;
|
setViewMode: (mode: AICourseViewMode) => void;
|
||||||
|
|
||||||
expandedModules: Record<number, boolean>;
|
expandedModules: Record<number, boolean>;
|
||||||
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>;
|
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>;
|
||||||
|
81
src/components/GenerateCourse/AIRoadmapViewSwitch.tsx
Normal file
81
src/components/GenerateCourse/AIRoadmapViewSwitch.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { BookOpenCheckIcon, SignpostIcon, type LucideIcon } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/classname';
|
||||||
|
import type { AICourseViewMode } from './AICourseContent';
|
||||||
|
|
||||||
|
type AIRoadmapViewSwitchProps = {
|
||||||
|
viewMode: AICourseViewMode;
|
||||||
|
setViewMode: (mode: AICourseViewMode) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
variant?: 'icon' | 'text';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AIRoadmapViewSwitch(props: AIRoadmapViewSwitchProps) {
|
||||||
|
const { viewMode, setViewMode, isLoading, variant = 'icon' } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid shrink-0 grid-cols-2 gap-0.5 rounded-md border border-gray-300 bg-white p-0.5 shadow-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SwitchButton
|
||||||
|
onClick={() => setViewMode('outline')}
|
||||||
|
isActive={viewMode === 'outline'}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant={variant}
|
||||||
|
icon={BookOpenCheckIcon}
|
||||||
|
label="Outline"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchButton
|
||||||
|
onClick={() => setViewMode('roadmap')}
|
||||||
|
isActive={viewMode === 'roadmap'}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant={variant}
|
||||||
|
icon={SignpostIcon}
|
||||||
|
label="Roadmap"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwitchButtonProps = {
|
||||||
|
onClick: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
variant?: 'icon' | 'text';
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SwitchButton(props: SwitchButtonProps) {
|
||||||
|
const {
|
||||||
|
onClick,
|
||||||
|
isActive,
|
||||||
|
disabled,
|
||||||
|
variant = 'icon',
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-1.5 rounded text-sm hover:bg-gray-100 disabled:cursor-not-allowed',
|
||||||
|
isActive && 'bg-gray-100 text-gray-800',
|
||||||
|
variant === 'text' ? 'px-2 py-1.5' : 'p-[5px]',
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
'size-4',
|
||||||
|
variant === 'icon' && 'h-3 w-3',
|
||||||
|
isActive && 'text-gray-800',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{variant === 'text' && label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
@@ -40,17 +40,20 @@ export function RegenerateOutline(props: RegenerateOutlineProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="absolute right-3 top-3" ref={ref}>
|
<div ref={ref} className="flex relative items-stretch">
|
||||||
<button
|
<button
|
||||||
className={cn('text-gray-400 hover:text-black', {
|
className={cn(
|
||||||
|
'rounded-md px-2.5 text-gray-400 hover:text-black',
|
||||||
|
{
|
||||||
'text-black': isDropdownVisible,
|
'text-black': isDropdownVisible,
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
onClick={() => setIsDropdownVisible(!isDropdownVisible)}
|
onClick={() => setIsDropdownVisible(!isDropdownVisible)}
|
||||||
>
|
>
|
||||||
<PenSquare className="text-current" size={16} strokeWidth={2.5} />
|
<PenSquare className="text-current" size={16} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
{isDropdownVisible && (
|
{isDropdownVisible && (
|
||||||
<div className="absolute right-0 top-full min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white">
|
<div className="absolute right-0 top-full translate-y-1 min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRegenerateOutline();
|
onRegenerateOutline();
|
||||||
|
@@ -12,7 +12,6 @@ import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generat
|
|||||||
import { renderFlowJSON } from '../../../editor/renderer/renderer';
|
import { renderFlowJSON } from '../../../editor/renderer/renderer';
|
||||||
import { replaceChildren } from '../../lib/dom';
|
import { replaceChildren } from '../../lib/dom';
|
||||||
import {
|
import {
|
||||||
getOpenAIKey,
|
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
removeAuthToken,
|
removeAuthToken,
|
||||||
setAIReferralCode,
|
setAIReferralCode,
|
||||||
@@ -30,7 +29,11 @@ import { showLoginPopup } from '../../lib/popup.ts';
|
|||||||
import { cn } from '../../lib/classname.ts';
|
import { cn } from '../../lib/classname.ts';
|
||||||
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
|
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
|
||||||
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
|
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
|
||||||
import { IS_KEY_ONLY_ROADMAP_GENERATION, readAIRoadmapStream } from '../../lib/ai.ts';
|
import {
|
||||||
|
generateAICourseRoadmapStructure,
|
||||||
|
IS_KEY_ONLY_ROADMAP_GENERATION,
|
||||||
|
readAIRoadmapStream,
|
||||||
|
} from '../../lib/ai.ts';
|
||||||
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
|
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
|
||||||
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
|
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
|
||||||
import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx';
|
import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx';
|
||||||
@@ -51,6 +54,7 @@ export type RoadmapNodeDetails = {
|
|||||||
targetGroup?: SVGElement;
|
targetGroup?: SVGElement;
|
||||||
nodeTitle?: string;
|
nodeTitle?: string;
|
||||||
parentTitle?: string;
|
parentTitle?: string;
|
||||||
|
parentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getNodeDetails(
|
export function getNodeDetails(
|
||||||
@@ -62,9 +66,10 @@ export function getNodeDetails(
|
|||||||
const nodeType = targetGroup?.dataset?.type;
|
const nodeType = targetGroup?.dataset?.type;
|
||||||
const nodeTitle = targetGroup?.dataset?.title;
|
const nodeTitle = targetGroup?.dataset?.title;
|
||||||
const parentTitle = targetGroup?.dataset?.parentTitle;
|
const parentTitle = targetGroup?.dataset?.parentTitle;
|
||||||
|
const parentId = targetGroup?.dataset?.parentId;
|
||||||
if (!nodeId || !nodeType) return null;
|
if (!nodeId || !nodeType) return null;
|
||||||
|
|
||||||
return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle };
|
return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle, parentId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const allowedClickableNodeTypes = [
|
export const allowedClickableNodeTypes = [
|
||||||
@@ -124,13 +129,11 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
|||||||
const [roadmapTopicLimitUsed, setRoadmapTopicLimitUsed] = useState(0);
|
const [roadmapTopicLimitUsed, setRoadmapTopicLimitUsed] = useState(0);
|
||||||
const [isConfiguring, setIsConfiguring] = useState(false);
|
const [isConfiguring, setIsConfiguring] = useState(false);
|
||||||
|
|
||||||
const [openAPIKey, setOpenAPIKey] = useState<string | undefined>(
|
|
||||||
getOpenAIKey(),
|
|
||||||
);
|
|
||||||
const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION;
|
const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION;
|
||||||
|
|
||||||
const renderRoadmap = async (roadmap: string) => {
|
const renderRoadmap = async (roadmap: string) => {
|
||||||
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
|
const result = generateAICourseRoadmapStructure(roadmap);
|
||||||
|
const { nodes, edges } = generateAIRoadmapFromText(result);
|
||||||
const svg = await renderFlowJSON({ nodes, edges });
|
const svg = await renderFlowJSON({ nodes, edges });
|
||||||
if (roadmapContainerRef?.current) {
|
if (roadmapContainerRef?.current) {
|
||||||
replaceChildren(roadmapContainerRef?.current, svg);
|
replaceChildren(roadmapContainerRef?.current, svg);
|
||||||
@@ -476,7 +479,6 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
|||||||
{isConfiguring && (
|
{isConfiguring && (
|
||||||
<IncreaseRoadmapLimit
|
<IncreaseRoadmapLimit
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setOpenAPIKey(getOpenAIKey());
|
|
||||||
setIsConfiguring(false);
|
setIsConfiguring(false);
|
||||||
loadAIRoadmapLimit().finally(() => null);
|
loadAIRoadmapLimit().finally(() => null);
|
||||||
}}
|
}}
|
||||||
@@ -519,29 +521,16 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
|||||||
<AIRoadmapAlert />
|
<AIRoadmapAlert />
|
||||||
{isKeyOnly && isAuthenticatedUser && (
|
{isKeyOnly && isAuthenticatedUser && (
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-4">
|
||||||
{!openAPIKey && (
|
|
||||||
<p className={'text-left text-red-500'}>
|
<p className={'text-left text-red-500'}>
|
||||||
We have hit the limit for AI roadmap generation. Please
|
We have hit the limit for AI roadmap generation. Please try
|
||||||
try again tomorrow or{' '}
|
again tomorrow or{' '}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsConfiguring(true)}
|
onClick={() => setIsConfiguring(true)}
|
||||||
className="font-semibold text-purple-600 underline underline-offset-2"
|
className="font-semibold text-purple-600 underline underline-offset-2"
|
||||||
>
|
>
|
||||||
add your own OpenAI API key
|
add more credits.
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
{openAPIKey && (
|
|
||||||
<p className={'text-left text-gray-500'}>
|
|
||||||
You have added your own OpenAI API key.{' '}
|
|
||||||
<button
|
|
||||||
onClick={() => setIsConfiguring(true)}
|
|
||||||
className="font-semibold text-purple-600 underline underline-offset-2"
|
|
||||||
>
|
|
||||||
Configure it here if you want.
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isKeyOnly && isAuthenticatedUser && (
|
{!isKeyOnly && isAuthenticatedUser && (
|
||||||
@@ -560,7 +549,6 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
|||||||
</span>{' '}
|
</span>{' '}
|
||||||
roadmaps generated today.
|
roadmaps generated today.
|
||||||
</span>
|
</span>
|
||||||
{!openAPIKey && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsConfiguring(true)}
|
onClick={() => setIsConfiguring(true)}
|
||||||
className="rounded-xl border border-current px-2 py-0.5 text-left text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
className="rounded-xl border border-current px-2 py-0.5 text-left text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
||||||
@@ -568,17 +556,6 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
|||||||
Need to generate more?{' '}
|
Need to generate more?{' '}
|
||||||
<span className="font-semibold">Click here.</span>
|
<span className="font-semibold">Click here.</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
{openAPIKey && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsConfiguring(true)}
|
|
||||||
className="flex flex-row items-center gap-1 rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
|
||||||
>
|
|
||||||
<Cog size={15} />
|
|
||||||
Configure OpenAI key
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isAuthenticatedUser && (
|
{!isAuthenticatedUser && (
|
||||||
@@ -621,7 +598,7 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
|||||||
!roadmapTerm ||
|
!roadmapTerm ||
|
||||||
roadmapLimitUsed >= roadmapLimit ||
|
roadmapLimitUsed >= roadmapLimit ||
|
||||||
roadmapTerm === currentRoadmap?.term ||
|
roadmapTerm === currentRoadmap?.term ||
|
||||||
(isKeyOnly && !openAPIKey)))
|
isKeyOnly))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoadingResults && (
|
{isLoadingResults && (
|
||||||
|
@@ -1,20 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { ChevronUp } from 'lucide-react';
|
|
||||||
import { Modal } from '../Modal';
|
import { Modal } from '../Modal';
|
||||||
import { ReferYourFriend } from './ReferYourFriend';
|
import { ReferYourFriend } from './ReferYourFriend';
|
||||||
import { OpenAISettings } from './OpenAISettings';
|
|
||||||
import { PayToBypass } from './PayToBypass';
|
import { PayToBypass } from './PayToBypass';
|
||||||
import { PickLimitOption } from './PickLimitOption';
|
import { PickLimitOption } from './PickLimitOption';
|
||||||
import { getOpenAIKey } from '../../lib/jwt.ts';
|
|
||||||
|
|
||||||
export type IncreaseTab = 'api-key' | 'refer-friends' | 'payment';
|
export type IncreaseTab = 'refer-friends' | 'payment';
|
||||||
|
|
||||||
export const increaseLimitTabs: {
|
export const increaseLimitTabs: {
|
||||||
key: IncreaseTab;
|
key: IncreaseTab;
|
||||||
title: string;
|
title: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ key: 'api-key', title: 'Add your own API Key' },
|
|
||||||
{ key: 'refer-friends', title: 'Refer your Friends' },
|
{ key: 'refer-friends', title: 'Refer your Friends' },
|
||||||
{ key: 'payment', title: 'Pay to Bypass the limit' },
|
{ key: 'payment', title: 'Pay to Bypass the limit' },
|
||||||
];
|
];
|
||||||
@@ -25,9 +21,8 @@ type IncreaseRoadmapLimitProps = {
|
|||||||
export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) {
|
export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) {
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
|
|
||||||
const openAPIKey = getOpenAIKey();
|
|
||||||
const [activeTab, setActiveTab] = useState<IncreaseTab | null>(
|
const [activeTab, setActiveTab] = useState<IncreaseTab | null>(
|
||||||
openAPIKey ? 'api-key' : null,
|
'refer-friends',
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,14 +39,6 @@ export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) {
|
|||||||
<PickLimitOption activeTab={activeTab} setActiveTab={setActiveTab} />
|
<PickLimitOption activeTab={activeTab} setActiveTab={setActiveTab} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'api-key' && (
|
|
||||||
<OpenAISettings
|
|
||||||
onClose={() => {
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
onBack={() => setActiveTab(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeTab === 'refer-friends' && (
|
{activeTab === 'refer-friends' && (
|
||||||
<ReferYourFriend onBack={() => setActiveTab(null)} />
|
<ReferYourFriend onBack={() => setActiveTab(null)} />
|
||||||
)}
|
)}
|
||||||
|
@@ -1,171 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { deleteOpenAIKey, getOpenAIKey, saveOpenAIKey } from '../../lib/jwt.ts';
|
|
||||||
import { cn } from '../../lib/classname.ts';
|
|
||||||
import { CloseIcon } from '../ReactIcons/CloseIcon.tsx';
|
|
||||||
import { useToast } from '../../hooks/use-toast.ts';
|
|
||||||
import { httpPost } from '../../lib/http.ts';
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
|
||||||
|
|
||||||
type OpenAISettingsProps = {
|
|
||||||
onClose: () => void;
|
|
||||||
onBack: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OpenAISettings(props: OpenAISettingsProps) {
|
|
||||||
const { onClose, onBack } = props;
|
|
||||||
|
|
||||||
const [defaultOpenAIKey, setDefaultOpenAIKey] = useState('');
|
|
||||||
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [openaiApiKey, setOpenaiApiKey] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const apiKey = getOpenAIKey();
|
|
||||||
setOpenaiApiKey(apiKey || '');
|
|
||||||
setDefaultOpenAIKey(apiKey || '');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
className="mb-5 flex items-center gap-1.5 text-sm leading-none opacity-40 transition-opacity hover:opacity-100 focus:outline-none"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={16} />
|
|
||||||
Back to options
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800">OpenAI Settings</h2>
|
|
||||||
<p className="mt-2 text-sm leading-normal text-gray-500">
|
|
||||||
Add your OpenAI API key below to bypass the roadmap generation limits.
|
|
||||||
You can use your existing key or{' '}
|
|
||||||
<a
|
|
||||||
className="underline underline-offset-2 hover:text-gray-900"
|
|
||||||
href={'https://platform.openai.com/signup'}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
create a new one here
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="mt-4"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
const normalizedKey = openaiApiKey.trim();
|
|
||||||
if (!normalizedKey) {
|
|
||||||
deleteOpenAIKey();
|
|
||||||
toast.success('OpenAI API key removed');
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizedKey.startsWith('sk-')) {
|
|
||||||
setError("Invalid OpenAI API key. It should start with 'sk-'");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
const { response, error } = await httpPost(
|
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-validate-openai-key`,
|
|
||||||
{
|
|
||||||
key: normalizedKey,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
setError(error.message);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the API key to cookies
|
|
||||||
saveOpenAIKey(normalizedKey);
|
|
||||||
toast.success('OpenAI API key saved');
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="openai-api-key"
|
|
||||||
id="openai-api-key"
|
|
||||||
className={cn(
|
|
||||||
'block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-800 transition-colors focus:border-black focus:outline-none',
|
|
||||||
{
|
|
||||||
'border-red-500 bg-red-100 focus:border-red-500': error,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
placeholder="Enter your OpenAI API key"
|
|
||||||
value={openaiApiKey}
|
|
||||||
onChange={(e) => {
|
|
||||||
setError('');
|
|
||||||
setOpenaiApiKey((e.target as HTMLInputElement).value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{openaiApiKey && (
|
|
||||||
<button
|
|
||||||
type={'button'}
|
|
||||||
onClick={() => {
|
|
||||||
setOpenaiApiKey('');
|
|
||||||
}}
|
|
||||||
className="absolute right-2 top-1/2 flex h-[20px] w-[20px] -translate-y-1/2 items-center justify-center rounded-full bg-gray-400 text-white hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
<CloseIcon className="h-[13px] w-[13px] stroke-[3.5]" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className={'mb-2 mt-1 text-xs text-gray-500'}>
|
|
||||||
We do not store your API key on our servers.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="mt-2 text-sm text-red-500">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
disabled={isLoading}
|
|
||||||
type="submit"
|
|
||||||
className={
|
|
||||||
'mt-2 w-full rounded-md bg-gray-700 px-4 py-2 text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-50'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{!isLoading && 'Save'}
|
|
||||||
{isLoading && 'Validating ..'}
|
|
||||||
</button>
|
|
||||||
{!defaultOpenAIKey && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
className="mt-1 w-full rounded-md border border-red-500 px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-700 hover:text-white"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{defaultOpenAIKey && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
deleteOpenAIKey();
|
|
||||||
onClose();
|
|
||||||
toast.success('OpenAI API key removed');
|
|
||||||
}}
|
|
||||||
className="mt-1 w-full rounded-md border border-red-500 px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-700 hover:text-white"
|
|
||||||
>
|
|
||||||
Remove API Key
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,10 +1,9 @@
|
|||||||
import { ArrowUpRight, Ban, Cog, Telescope, Wand } from 'lucide-react';
|
import { ArrowUpRight, Ban, Cog, Telescope, Wand } from 'lucide-react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getOpenAIKey, isLoggedIn } from '../../lib/jwt';
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
import { cn } from '../../lib/classname.ts';
|
import { cn } from '../../lib/classname.ts';
|
||||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
|
||||||
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
|
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
|
||||||
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
|
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
|
||||||
|
|
||||||
@@ -33,12 +32,10 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
|
|
||||||
const canGenerateMore = limitUsed < limit;
|
const canGenerateMore = limitUsed < limit;
|
||||||
const [isConfiguring, setIsConfiguring] = useState(false);
|
const [isConfiguring, setIsConfiguring] = useState(false);
|
||||||
const [openAPIKey, setOpenAPIKey] = useState('');
|
|
||||||
const [isAuthenticatedUser, setIsAuthenticatedUser] = useState(false);
|
const [isAuthenticatedUser, setIsAuthenticatedUser] = useState(false);
|
||||||
const [isLoadingResults, setIsLoadingResults] = useState(false);
|
const [isLoadingResults, setIsLoadingResults] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpenAPIKey(getOpenAIKey() || '');
|
|
||||||
setIsAuthenticatedUser(isLoggedIn());
|
setIsAuthenticatedUser(isLoggedIn());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -49,7 +46,6 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
{isConfiguring && (
|
{isConfiguring && (
|
||||||
<IncreaseRoadmapLimit
|
<IncreaseRoadmapLimit
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setOpenAPIKey(getOpenAIKey()!);
|
|
||||||
setIsConfiguring(false);
|
setIsConfiguring(false);
|
||||||
loadAIRoadmapLimit();
|
loadAIRoadmapLimit();
|
||||||
}}
|
}}
|
||||||
@@ -104,10 +100,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
disabled={
|
disabled={
|
||||||
isLoadingResults ||
|
isLoadingResults ||
|
||||||
(isAuthenticatedUser &&
|
(isAuthenticatedUser &&
|
||||||
(!limit ||
|
(!limit || !roadmapTerm || limitUsed >= limit || isKeyOnly))
|
||||||
!roadmapTerm ||
|
|
||||||
limitUsed >= limit ||
|
|
||||||
(isKeyOnly && !openAPIKey)))
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoadingResults && (
|
{isLoadingResults && (
|
||||||
@@ -202,31 +195,16 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
)}
|
)}
|
||||||
{isKeyOnly && isAuthenticatedUser && (
|
{isKeyOnly && isAuthenticatedUser && (
|
||||||
<div className="mx-auto mt-12 flex max-w-[450px] flex-col items-center gap-4">
|
<div className="mx-auto mt-12 flex max-w-[450px] flex-col items-center gap-4">
|
||||||
{!openAPIKey && (
|
|
||||||
<>
|
|
||||||
<p className={'text-center text-red-500'}>
|
<p className={'text-center text-red-500'}>
|
||||||
We have hit the limit for AI roadmap generation. Please try
|
We have hit the limit for AI roadmap generation. Please try again
|
||||||
again later or{' '}
|
again later or{' '}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsConfiguring(true)}
|
onClick={() => setIsConfiguring(true)}
|
||||||
className="font-semibold text-purple-600 underline underline-offset-2"
|
className="font-semibold text-purple-600 underline underline-offset-2"
|
||||||
>
|
>
|
||||||
add your own OpenAI API key.
|
get more credits.
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{openAPIKey && (
|
|
||||||
<p className={'text-center text-gray-500'}>
|
|
||||||
You have added your own OpenAI API key.{' '}
|
|
||||||
<button
|
|
||||||
onClick={() => setIsConfiguring(true)}
|
|
||||||
className="font-semibold text-purple-600 underline underline-offset-2"
|
|
||||||
>
|
|
||||||
Configure it here if you want.
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="flex flex-col gap-2 text-center text-gray-500 sm:flex-row">
|
<p className="flex flex-col gap-2 text-center text-gray-500 sm:flex-row">
|
||||||
<a
|
<a
|
||||||
@@ -259,7 +237,6 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
</p>
|
</p>
|
||||||
{isAuthenticatedUser && (
|
{isAuthenticatedUser && (
|
||||||
<p className="flex items-center text-sm">
|
<p className="flex items-center text-sm">
|
||||||
{!openAPIKey && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsConfiguring(true)}
|
onClick={() => setIsConfiguring(true)}
|
||||||
className="rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
className="rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
||||||
@@ -267,17 +244,6 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
|
|||||||
Need to generate more?{' '}
|
Need to generate more?{' '}
|
||||||
<span className="font-semibold">Click here.</span>
|
<span className="font-semibold">Click here.</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
{openAPIKey && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsConfiguring(true)}
|
|
||||||
className="flex flex-row items-center gap-1 rounded-xl border border-current px-2 py-0.5 text-sm text-blue-500 transition-colors hover:bg-blue-400 hover:text-white"
|
|
||||||
>
|
|
||||||
<Cog size={15} />
|
|
||||||
Configure OpenAI key
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -3,10 +3,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useKeydown } from '../../hooks/use-keydown';
|
import { useKeydown } from '../../hooks/use-keydown';
|
||||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||||
import { markdownToHtml } from '../../lib/markdown';
|
import { markdownToHtml } from '../../lib/markdown';
|
||||||
import { Ban, Cog, Contact, FileText, X } from 'lucide-react';
|
import { Ban, Contact, FileText, X } from 'lucide-react';
|
||||||
import { Spinner } from '../ReactIcons/Spinner';
|
import { Spinner } from '../ReactIcons/Spinner';
|
||||||
import type { RoadmapNodeDetails } from './GenerateRoadmap';
|
import type { RoadmapNodeDetails } from './GenerateRoadmap';
|
||||||
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
import { readAIRoadmapContentStream } from '../../lib/ai';
|
import { readAIRoadmapContentStream } from '../../lib/ai';
|
||||||
@@ -121,7 +121,6 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hasContent = topicHtml?.length > 0;
|
const hasContent = topicHtml?.length > 0;
|
||||||
const openAIKey = getOpenAIKey();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'relative z-[92]'}>
|
<div className={'relative z-[92]'}>
|
||||||
@@ -146,7 +145,6 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
|
|||||||
</span>{' '}
|
</span>{' '}
|
||||||
topics generated
|
topics generated
|
||||||
</span>
|
</span>
|
||||||
{!openAIKey && (
|
|
||||||
<button
|
<button
|
||||||
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
className="rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
||||||
onClick={onConfigureOpenAI}
|
onClick={onConfigureOpenAI}
|
||||||
@@ -154,16 +152,6 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
|
|||||||
Need to generate more?{' '}
|
Need to generate more?{' '}
|
||||||
<span className="font-semibold">Click here.</span>
|
<span className="font-semibold">Click here.</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
{openAIKey && (
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-1 rounded-xl border border-current px-1.5 py-0.5 text-left text-sm font-medium text-blue-500 sm:text-center"
|
|
||||||
onClick={onConfigureOpenAI}
|
|
||||||
>
|
|
||||||
<Cog className="-mt-0.5 inline-block h-4 w-4" />
|
|
||||||
Configure OpenAI Key
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
export const IS_KEY_ONLY_ROADMAP_GENERATION = false;
|
export const IS_KEY_ONLY_ROADMAP_GENERATION = false;
|
||||||
|
|
||||||
type Lesson = string;
|
type Lesson = string;
|
||||||
@@ -52,6 +54,7 @@ export function generateAiCourseStructure(
|
|||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
modules,
|
modules,
|
||||||
|
done: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +126,7 @@ export async function readAIRoadmapStream(
|
|||||||
for (let i = 0; i < value.length; i++) {
|
for (let i = 0; i < value.length; i++) {
|
||||||
if (value[i] === NEW_LINE) {
|
if (value[i] === NEW_LINE) {
|
||||||
result += decoder.decode(value.slice(start, i + 1));
|
result += decoder.decode(value.slice(start, i + 1));
|
||||||
onStream?.(result);
|
await onStream?.(result);
|
||||||
start = i + 1;
|
start = i + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,8 +136,8 @@ export async function readAIRoadmapStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onStream?.(result);
|
await onStream?.(result);
|
||||||
onStreamEnd?.(result);
|
await onStreamEnd?.(result);
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,3 +210,93 @@ export async function readStream(
|
|||||||
onStreamEnd?.(result);
|
onStreamEnd?.(result);
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubTopic = {
|
||||||
|
id: string;
|
||||||
|
type: 'subtopic';
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Topic = {
|
||||||
|
id: string;
|
||||||
|
type: 'topic';
|
||||||
|
label: string;
|
||||||
|
children?: SubTopic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Label = {
|
||||||
|
id: string;
|
||||||
|
type: 'label';
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Title = {
|
||||||
|
id: string;
|
||||||
|
type: 'title';
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResultItem = Title | Topic | Label;
|
||||||
|
|
||||||
|
export function generateAICourseRoadmapStructure(
|
||||||
|
data: string,
|
||||||
|
isCourseRoadmap: boolean = false,
|
||||||
|
): ResultItem[] {
|
||||||
|
const lines = data.split('\n');
|
||||||
|
|
||||||
|
const result: ResultItem[] = [];
|
||||||
|
let currentTopic: Topic | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (line.startsWith('###')) {
|
||||||
|
if (currentTopic) {
|
||||||
|
result.push(currentTopic);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = line.replace('###', '').trim();
|
||||||
|
currentTopic = {
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'topic',
|
||||||
|
label,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
} else if (line.startsWith('##')) {
|
||||||
|
result.push({
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'label',
|
||||||
|
label: line.replace('##', '').trim(),
|
||||||
|
});
|
||||||
|
} else if (i === 0 && line.startsWith('#')) {
|
||||||
|
const title = line.replace('#', '').trim();
|
||||||
|
result.push({
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'title',
|
||||||
|
label: title,
|
||||||
|
});
|
||||||
|
} else if (line.startsWith('-')) {
|
||||||
|
if (currentTopic) {
|
||||||
|
const label = line.replace('-', '').trim();
|
||||||
|
|
||||||
|
let id = nanoid();
|
||||||
|
if (isCourseRoadmap) {
|
||||||
|
const currentTopicIndex = result.length - 1;
|
||||||
|
const subTopicIndex = currentTopic.children?.length || 0;
|
||||||
|
id = `${currentTopicIndex}-${subTopicIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTopic.children?.push({
|
||||||
|
id,
|
||||||
|
type: 'subtopic',
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTopic) {
|
||||||
|
result.push(currentTopic);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
@@ -70,27 +70,6 @@ export function visitAIRoadmap(roadmapId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteOpenAIKey() {
|
|
||||||
Cookies.remove('oak', {
|
|
||||||
path: '/',
|
|
||||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveOpenAIKey(apiKey: string) {
|
|
||||||
Cookies.set('oak', apiKey, {
|
|
||||||
path: '/',
|
|
||||||
expires: 365,
|
|
||||||
sameSite: 'lax',
|
|
||||||
secure: true,
|
|
||||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOpenAIKey() {
|
|
||||||
return Cookies.get('oak');
|
|
||||||
}
|
|
||||||
|
|
||||||
const AI_REFERRAL_COOKIE_NAME = 'referral_code';
|
const AI_REFERRAL_COOKIE_NAME = 'referral_code';
|
||||||
|
|
||||||
export function setAIReferralCode(code: string) {
|
export function setAIReferralCode(code: string) {
|
||||||
|
Reference in New Issue
Block a user