diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index c080c959b..37c197cc1 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -39,7 +39,7 @@ export function AICourseContent(props: AICourseContentProps) { const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); - const [isAIChatsOpen, setIsAIChatsOpen] = useState(false); + const [isAIChatsOpen, setIsAIChatsOpen] = useState(true); const [activeModuleIndex, setActiveModuleIndex] = useState(0); const [activeLessonIndex, setActiveLessonIndex] = useState(0); @@ -144,6 +144,12 @@ export function AICourseContent(props: AICourseContentProps) { </> ); + useEffect(() => { + if (window && window?.innerWidth < 1024 && isAIChatsOpen) { + setIsAIChatsOpen(false); + } + }, []); + if (error && !isLoading) { const isLimitReached = error.includes('limit'); const isNotFound = error.includes('not exist'); @@ -239,18 +245,16 @@ export function AICourseContent(props: AICourseContentProps) { /> </div> - {viewMode === 'module' && ( - <button - onClick={() => setIsAIChatsOpen(!isAIChatsOpen)} - className="ml-1.5 flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden" - > - {isAIChatsOpen ? ( - <MessageCircleOffIcon size={17} strokeWidth={3} /> - ) : ( - <MessageCircleIcon size={17} strokeWidth={3} /> - )} - </button> - )} + <button + onClick={() => setIsAIChatsOpen(!isAIChatsOpen)} + className="flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden" + > + {isAIChatsOpen ? ( + <MessageCircleOffIcon size={17} strokeWidth={3} /> + ) : ( + <MessageCircleIcon size={17} strokeWidth={3} /> + )} + </button> <button onClick={() => setSidebarOpen(!sidebarOpen)} diff --git a/src/components/GenerateCourse/AICourseLesson.tsx b/src/components/GenerateCourse/AICourseLesson.tsx index 2ef9abb46..5ecf1097f 100644 --- a/src/components/GenerateCourse/AICourseLesson.tsx +++ b/src/components/GenerateCourse/AICourseLesson.tsx @@ -29,7 +29,10 @@ import { queryClient } from '../../stores/query-client'; import './AICourseLessonChat.css'; import { RegenerateLesson } from './RegenerateLesson'; import { TestMyKnowledgeAction } from './TestMyKnowledgeAction'; -import { AICourseLessonChat } from './AICourseLessonChat'; +import { + AICourseLessonChat, + type AIChatHistoryType, +} from './AICourseLessonChat'; import { AICourseFooter } from './AICourseFooter'; import { ResizableHandle, @@ -66,7 +69,7 @@ type AICourseLessonProps = { onUpgrade: () => void; isAIChatsOpen: boolean; - setIsAIChatsOpen: (isAIChatsOpen: boolean) => void; + setIsAIChatsOpen: (isOpen: boolean) => void; }; export function AICourseLesson(props: AICourseLessonProps) { @@ -86,11 +89,10 @@ export function AICourseLesson(props: AICourseLessonProps) { onUpgrade, - isAIChatsOpen: isAIChatsMobileOpen, - setIsAIChatsOpen: setIsAIChatsMobileOpen, + isAIChatsOpen, + setIsAIChatsOpen, } = props; - const [isAIChatsOpen, setIsAIChatsOpen] = useState(true); const [isLoading, setIsLoading] = useState(true); const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(''); @@ -101,6 +103,17 @@ export function AICourseLesson(props: AICourseLessonProps) { const lessonId = `${slugify(String(activeModuleIndex))}-${slugify(String(activeLessonIndex))}`; const isLessonDone = progress?.includes(lessonId); + const [courseAIChatHistory, setCourseAIChatHistory] = useState< + AIChatHistoryType[] + >([ + { + role: 'assistant', + content: + 'Hey, I am your AI instructor. Here are some examples of what you can ask me about 🤖', + isDefault: true, + }, + ]); + const { isPaidUser } = useIsPaidUser(); const abortController = useMemo( @@ -460,47 +473,20 @@ export function AICourseLesson(props: AICourseLessonProps) { {isAIChatsOpen && ( <> <ResizableHandle withHandle={false} className="max-lg:hidden" /> - <ResizablePanel - defaultSize={40} - minSize={20} - id="course-chat-content" - order={2} - className="max-lg:hidden" - > - <AICourseLessonChat - courseSlug={courseSlug} - moduleTitle={currentModuleTitle} - lessonTitle={currentLessonTitle} - onUpgradeClick={onUpgrade} - isDisabled={isGenerating || isLoading || isTogglingDone} - defaultQuestions={defaultQuestions} - /> - </ResizablePanel> - </> - )} - - {isAIChatsMobileOpen && ( - <div - className="fixed inset-0 hidden data-[state=open]:block lg:hidden data-[state=open]:lg:hidden" - data-state={isAIChatsMobileOpen ? 'open' : 'closed'} - > - <div className="absolute inset-0 bg-black/50" /> <AICourseLessonChat courseSlug={courseSlug} moduleTitle={currentModuleTitle} lessonTitle={currentLessonTitle} onUpgradeClick={onUpgrade} + courseAIChatHistory={courseAIChatHistory} + setCourseAIChatHistory={setCourseAIChatHistory} isDisabled={isGenerating || isLoading || isTogglingDone} defaultQuestions={defaultQuestions} + onClose={() => setIsAIChatsOpen(false)} + isAIChatsOpen={isAIChatsOpen} + setIsAIChatsOpen={setIsAIChatsOpen} /> - - <button - onClick={() => setIsAIChatsMobileOpen(false)} - className="absolute right-2 top-2 z-20 rounded-full p-1 text-gray-400 hover:text-black" - > - <XIcon className="size-4 stroke-[2.5]" /> - </button> - </div> + </> )} </ResizablePanelGroup> </div> diff --git a/src/components/GenerateCourse/AICourseLessonChat.tsx b/src/components/GenerateCourse/AICourseLessonChat.tsx index 7553800de..25717b259 100644 --- a/src/components/GenerateCourse/AICourseLessonChat.tsx +++ b/src/components/GenerateCourse/AICourseLessonChat.tsx @@ -6,6 +6,7 @@ import { HelpCircle, LockIcon, Send, + XIcon, } from 'lucide-react'; import { Fragment, @@ -29,6 +30,7 @@ import { import { getAiCourseLimitOptions } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; import { billingDetailsOptions } from '../../queries/billing'; +import { ResizablePanel } from './Resizeable'; export type AllowedAIChatRole = 'user' | 'assistant'; export type AIChatHistoryType = { @@ -46,6 +48,14 @@ type AICourseLessonChatProps = { isDisabled?: boolean; defaultQuestions?: string[]; + + courseAIChatHistory: AIChatHistoryType[]; + setCourseAIChatHistory: (history: AIChatHistoryType[]) => void; + + onClose: () => void; + + isAIChatsOpen: boolean; + setIsAIChatsOpen: (isOpen: boolean) => void; }; export function AICourseLessonChat(props: AICourseLessonChatProps) { @@ -56,23 +66,20 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) { onUpgradeClick, isDisabled, defaultQuestions = [], + + courseAIChatHistory, + setCourseAIChatHistory, + + onClose, + + isAIChatsOpen, + setIsAIChatsOpen, } = props; const toast = useToast(); const scrollareaRef = useRef<HTMLDivElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null); - const [courseAIChatHistory, setCourseAIChatHistory] = useState< - AIChatHistoryType[] - >([ - { - role: 'assistant', - content: - 'Hey, I am your AI instructor. Here are some examples of what you can ask me about 🤖', - isDefault: true, - }, - ]); - const [isStreamingMessage, setIsStreamingMessage] = useState(false); const [message, setMessage] = useState(''); const [streamedMessage, setStreamedMessage] = useState(''); @@ -203,132 +210,147 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) { }, []); return ( - <> - <div className="relative h-full"> - <div className="absolute inset-y-0 right-0 z-20 flex w-full flex-col overflow-hidden bg-white"> - <div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm"> - <h4 className="text-base font-medium">Course AI</h4> - </div> + <ResizablePanel + defaultSize={isAIChatsOpen ? 40 : 0} + minSize={20} + id="course-chat-content" + order={2} + className="relative h-full max-lg:fixed max-lg:inset-0 max-lg:data-[chat-state=open]:flex max-lg:data-[chat-state=closed]:hidden" + data-chat-state={isAIChatsOpen ? 'open' : 'closed'} + > + <div + className="absolute inset-y-0 right-0 z-20 flex w-full flex-col overflow-hidden bg-white data-[state=open]:flex data-[state=closed]:hidden" + data-state={isAIChatsOpen ? 'open' : 'closed'} + > + <button + onClick={onClose} + className="absolute right-2 top-2 z-20 hidden rounded-full p-1 text-gray-400 hover:text-black max-lg:block" + > + <XIcon className="size-4 stroke-[2.5]" /> + </button> - <div - className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto" - ref={scrollareaRef} - > - <div className="absolute inset-0 flex flex-col"> - <div className="flex grow flex-col justify-end"> - <div className="flex flex-col justify-end gap-2 px-3 py-2"> - {courseAIChatHistory.map((chat, index) => { - return ( - <Fragment key={`chat-${index}`}> - <AIChatCard - role={chat.role} - content={chat.content} - html={chat.html} - /> - - {chat.isDefault && !defaultQuestions?.length && ( - <div className="mb-1 mt-0.5"> - <div className="grid grid-cols-2 gap-2"> - {capabilities.map((capability, index) => ( - <CapabilityCard - key={`capability-${index}`} - {...capability} - /> - ))} - </div> + <div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm"> + <h4 className="text-base font-medium">Course AI</h4> + </div> + + <div + className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto" + ref={scrollareaRef} + > + <div className="absolute inset-0 flex flex-col"> + <div className="flex grow flex-col justify-end"> + <div className="flex flex-col justify-end gap-2 px-3 py-2"> + {courseAIChatHistory.map((chat, index) => { + return ( + <Fragment key={`chat-${index}`}> + <AIChatCard + role={chat.role} + content={chat.content} + html={chat.html} + /> + + {chat.isDefault && !defaultQuestions?.length && ( + <div className="mb-1 mt-0.5"> + <div className="grid grid-cols-2 gap-2"> + {capabilities.map((capability, index) => ( + <CapabilityCard + key={`capability-${index}`} + {...capability} + /> + ))} </div> - )} - - {chat.isDefault && defaultQuestions?.length > 1 && ( - <div className="mb-1 mt-0.5"> - <div className="grid grid-cols-2 items-stretch gap-2"> - {defaultQuestions.map((question, index) => ( - <button - key={`default-question-${index}`} - className="flex h-full items-start self-start rounded-md bg-yellow-500/10 p-2 text-left text-sm text-black hover:bg-yellow-500/20" - onClick={() => { - flushSync(() => { - setMessage(question); - }); - - textareaRef.current?.focus(); - }} - > - {question} - </button> - ))} - </div> + </div> + )} + + {chat.isDefault && defaultQuestions?.length > 1 && ( + <div className="mb-1 mt-0.5"> + <div className="grid grid-cols-2 items-stretch gap-2"> + {defaultQuestions.map((question, index) => ( + <button + key={`default-question-${index}`} + className="flex h-full items-start self-start rounded-md bg-yellow-500/10 p-2 text-left text-sm text-black hover:bg-yellow-500/20" + onClick={() => { + flushSync(() => { + setMessage(question); + }); + + textareaRef.current?.focus(); + }} + > + {question} + </button> + ))} </div> - )} - </Fragment> - ); - })} - - {isStreamingMessage && !streamedMessage && ( - <AIChatCard role="assistant" content="Thinking..." /> - )} - - {streamedMessage && ( - <AIChatCard role="assistant" content={streamedMessage} /> - )} - </div> + </div> + )} + </Fragment> + ); + })} + + {isStreamingMessage && !streamedMessage && ( + <AIChatCard role="assistant" content="Thinking..." /> + )} + + {streamedMessage && ( + <AIChatCard role="assistant" content={streamedMessage} /> + )} </div> </div> </div> + </div> - <form - className="relative flex items-start border-t border-gray-200 text-sm" - onSubmit={handleChatSubmit} - > - {isLimitExceeded && ( - <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} - /> - <p className="cursor-not-allowed"> - Limit reached for today - {isPaidUser ? '. Please wait until tomorrow.' : ''} - </p> - {!isPaidUser && ( - <button - onClick={() => { - onUpgradeClick(); - }} - className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300" - > - Upgrade for more - </button> - )} - </div> - )} - <TextareaAutosize - className={cn( - 'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-none', - isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-auto', + <form + className="relative flex items-start border-t border-gray-200 text-sm" + onSubmit={handleChatSubmit} + > + {isLimitExceeded && ( + <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} + /> + <p className="cursor-not-allowed"> + Limit reached for today + {isPaidUser ? '. Please wait until tomorrow.' : ''} + </p> + {!isPaidUser && ( + <button + onClick={() => { + onUpgradeClick(); + }} + className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300" + > + Upgrade for more + </button> )} - placeholder="Ask AI anything about the lesson..." - value={message} - onChange={(e) => setMessage(e.target.value)} - autoFocus={true} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>); - } - }} - ref={textareaRef} - /> - <button - type="submit" - disabled={isDisabled || isStreamingMessage || isLimitExceeded} - className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50" - > - <Send className="size-4 stroke-[2.5]" /> - </button> - </form> - </div> + </div> + )} + <TextareaAutosize + className={cn( + 'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-none', + isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-auto', + )} + placeholder="Ask AI anything about the lesson..." + value={message} + onChange={(e) => setMessage(e.target.value)} + autoFocus={true} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>); + } + }} + ref={textareaRef} + /> + <button + type="submit" + disabled={isDisabled || isStreamingMessage || isLimitExceeded} + className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50" + > + <Send className="size-4 stroke-[2.5]" /> + </button> + </form> </div> - </> + </ResizablePanel> ); }