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>pull/8411/head
parent
80a4ebbb3d
commit
85202507e6
17 changed files with 784 additions and 498 deletions
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
|
||||||
); |
|
||||||
} |
|
Loading…
Reference in new issue