feat: make chat resizeable

feat/chat
Arik Chakma 4 weeks ago
parent ebfdaf3586
commit bfce0b4bb7
  1. 1
      package.json
  2. 14
      pnpm-lock.yaml
  3. 8
      src/components/GenerateCourse/AICourseContent.tsx
  4. 426
      src/components/GenerateCourse/AICourseLesson.tsx
  5. 36
      src/components/GenerateCourse/AICourseLessonChat.tsx
  6. 42
      src/components/GenerateCourse/Resizeable.tsx

@ -64,6 +64,7 @@
"react-calendar-heatmap": "^1.9.0", "react-calendar-heatmap": "^1.9.0",
"react-confetti": "^6.1.0", "react-confetti": "^6.1.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-resizable-panels": "^2.1.7",
"react-textarea-autosize": "^8.5.7", "react-textarea-autosize": "^8.5.7",
"react-tooltip": "^5.28.0", "react-tooltip": "^5.28.0",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",

@ -113,6 +113,9 @@ importers:
react-dom: react-dom:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-resizable-panels:
specifier: ^2.1.7
version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-textarea-autosize: react-textarea-autosize:
specifier: ^8.5.7 specifier: ^8.5.7
version: 8.5.7(@types/react@18.3.18)(react@18.3.1) version: 8.5.7(@types/react@18.3.18)(react@18.3.1)
@ -2988,6 +2991,12 @@ packages:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
react-resizable-panels@2.1.7:
resolution: {integrity: sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==}
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-textarea-autosize@8.5.7: react-textarea-autosize@8.5.7:
resolution: {integrity: sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ==} resolution: {integrity: sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -6503,6 +6512,11 @@ snapshots:
react-refresh@0.14.2: {} react-refresh@0.14.2: {}
react-resizable-panels@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-textarea-autosize@8.5.7(@types/react@18.3.18)(react@18.3.1): react-textarea-autosize@8.5.7(@types/react@18.3.18)(react@18.3.1):
dependencies: dependencies:
'@babel/runtime': 7.26.9 '@babel/runtime': 7.26.9

@ -39,7 +39,7 @@ export function AICourseContent(props: AICourseContentProps) {
const [showUpgradeModal, setShowUpgradeModal] = useState(false); const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
const [isAIChatsOpen, setIsAIChatsOpen] = useState(true); const [isAIChatsOpen, setIsAIChatsOpen] = useState(false);
const [activeModuleIndex, setActiveModuleIndex] = useState(0); const [activeModuleIndex, setActiveModuleIndex] = useState(0);
const [activeLessonIndex, setActiveLessonIndex] = useState(0); const [activeLessonIndex, setActiveLessonIndex] = useState(0);
@ -211,12 +211,6 @@ export function AICourseContent(props: AICourseContentProps) {
const isViewingLesson = viewMode === 'module'; const isViewingLesson = viewMode === 'module';
useEffect(() => {
if (window && window?.innerWidth < 1024 && isAIChatsOpen) {
setIsAIChatsOpen(false);
}
}, []);
return ( return (
<section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50"> <section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50">
{modals} {modals}

@ -31,6 +31,11 @@ import { RegenerateLesson } from './RegenerateLesson';
import { TestMyKnowledgeAction } from './TestMyKnowledgeAction'; import { TestMyKnowledgeAction } from './TestMyKnowledgeAction';
import { AICourseLessonChat } from './AICourseLessonChat'; import { AICourseLessonChat } from './AICourseLessonChat';
import { AICourseFooter } from './AICourseFooter'; import { AICourseFooter } from './AICourseFooter';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from './Resizeable';
type AICourseLessonProps = { type AICourseLessonProps = {
courseSlug: string; courseSlug: string;
@ -69,10 +74,11 @@ export function AICourseLesson(props: AICourseLessonProps) {
onUpgrade, onUpgrade,
isAIChatsOpen, isAIChatsOpen: isAIChatsMobileOpen,
setIsAIChatsOpen, setIsAIChatsOpen: setIsAIChatsMobileOpen,
} = props; } = props;
const [isAIChatsOpen, setIsAIChatsOpen] = useState(true);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -218,217 +224,253 @@ export function AICourseLesson(props: AICourseLessonProps) {
isLoading; isLoading;
return ( return (
<div className="grid h-full grid-cols-5"> <div className="h-full">
<div <ResizablePanelGroup direction="horizontal">
className={cn( <ResizablePanel
'relative', defaultSize={isAIChatsOpen ? 60 : 100}
isAIChatsOpen ? 'col-span-3 max-lg:col-span-5' : 'col-span-5', minSize={40}
)} id="course-text-content"
> order={1}
<div className="absolute inset-0 overflow-y-auto bg-white p-8 pb-0 max-lg:px-4 max-lg:pt-3"> >
{(isGenerating || isLoading) && ( <div className="relative h-full">
<div className="absolute right-6 top-6 flex items-center justify-center"> <div className="absolute inset-0 overflow-y-auto bg-white p-8 pb-0 max-lg:px-4 max-lg:pt-3">
<Loader2Icon {(isGenerating || isLoading) && (
size={18} <div className="absolute right-6 top-6 flex items-center justify-center">
strokeWidth={3} <Loader2Icon
className="animate-spin text-gray-400/70" size={18}
/> strokeWidth={3}
</div> className="animate-spin text-gray-400/70"
)} />
</div>
)}
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
Lesson {activeLessonIndex + 1} of {totalLessons} Lesson {activeLessonIndex + 1} of {totalLessons}
</div> </div>
{!isGenerating && !isLoading && ( {!isGenerating && !isLoading && (
<div className="absolute right-6 top-6 flex items-center justify-between gap-2"> <div className="absolute right-6 top-6 flex items-center justify-between gap-2">
<button <button
onClick={() => setIsAIChatsOpen(!isAIChatsOpen)} onClick={() => setIsAIChatsOpen(!isAIChatsOpen)}
className="rounded-full p-1 text-gray-400 hover:text-black max-lg:hidden" className="rounded-full p-1 text-gray-400 hover:text-black max-lg:hidden"
> >
{!isAIChatsOpen ? ( {!isAIChatsOpen ? (
<MessageCircleIcon className="size-4 stroke-[2.5]" /> <MessageCircleIcon className="size-4 stroke-[2.5]" />
) : ( ) : (
<MessageCircleOffIcon className="size-4 stroke-[2.5]" /> <MessageCircleOffIcon className="size-4 stroke-[2.5]" />
)} )}
</button> </button>
<RegenerateLesson <RegenerateLesson
onRegenerateLesson={(prompt) => { onRegenerateLesson={(prompt) => {
generateAiCourseContent(true, prompt); generateAiCourseContent(true, prompt);
}} }}
/> />
<button <button
disabled={isLoading || isTogglingDone} disabled={isLoading || isTogglingDone}
className={cn( className={cn(
'flex items-center gap-1.5 rounded-full bg-black py-1 pl-2 pr-3 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs', 'flex items-center gap-1.5 rounded-full bg-black py-1 pl-2 pr-3 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs',
isLessonDone isLessonDone
? 'bg-red-500 hover:bg-red-600' ? 'bg-red-500 hover:bg-red-600'
: 'bg-green-500 hover:bg-green-600', : 'bg-green-500 hover:bg-green-600',
)} )}
onClick={() => toggleDone()} onClick={() => toggleDone()}
> >
{isTogglingDone ? ( {isTogglingDone ? (
<>
<Loader2Icon
size={16}
strokeWidth={3}
className="animate-spin text-white"
/>
Please wait ...
</>
) : (
<>
{isLessonDone ? (
<> <>
<XIcon size={16} /> <Loader2Icon
Mark as Undone size={16}
strokeWidth={3}
className="animate-spin text-white"
/>
Please wait ...
</> </>
) : ( ) : (
<> <>
<CheckIcon size={16} /> {isLessonDone ? (
Mark as Done <>
<XIcon size={16} />
Mark as Undone
</>
) : (
<>
<CheckIcon size={16} />
Mark as Done
</>
)}
</> </>
)} )}
</> </button>
)} </div>
</button> )}
</div> </div>
)}
</div>
<h1 className="mb-6 text-balance text-3xl font-semibold max-lg:mb-3 max-lg:text-xl"> <h1 className="mb-6 text-balance text-3xl font-semibold max-lg:mb-3 max-lg:text-xl">
{currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')} {currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
</h1> </h1>
{!error && isLoggedIn() && (
<div
className="course-content prose prose-lg mt-8 max-w-full text-black prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800 max-lg:mt-4 max-lg:text-base max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm"
dangerouslySetInnerHTML={{ __html: lessonHtml }}
/>
)}
{error && isLoggedIn() && (
<div className="mt-8 flex min-h-[300px] items-center justify-center rounded-xl bg-red-50/80">
{error.includes('reached the limit') ? (
<div className="flex max-w-sm flex-col items-center text-center">
<h2 className="text-xl font-semibold text-red-600">
Limit reached
</h2>
<p className="my-3 text-red-600">
You have reached the AI usage limit for today.
{!isPaidUser && (
<>Please upgrade your account to continue.</>
)}
{isPaidUser && (
<>&nbsp;Please wait until tomorrow to continue.</>
)}
</p>
{!isPaidUser && ( {!error && isLoggedIn() && (
<button <div
onClick={() => { className="course-content prose prose-lg mt-8 max-w-full text-black prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800 max-lg:mt-4 max-lg:text-base max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm"
onUpgrade(); dangerouslySetInnerHTML={{ __html: lessonHtml }}
}} />
className="rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700" )}
>
Upgrade Account {error && isLoggedIn() && (
</button> <div className="mt-8 flex min-h-[300px] items-center justify-center rounded-xl bg-red-50/80">
{error.includes('reached the limit') ? (
<div className="flex max-w-sm flex-col items-center text-center">
<h2 className="text-xl font-semibold text-red-600">
Limit reached
</h2>
<p className="my-3 text-red-600">
You have reached the AI usage limit for today.
{!isPaidUser && (
<>Please upgrade your account to continue.</>
)}
{isPaidUser && (
<>&nbsp;Please wait until tomorrow to continue.</>
)}
</p>
{!isPaidUser && (
<button
onClick={() => {
onUpgrade();
}}
className="rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700"
>
Upgrade Account
</button>
)}
</div>
) : (
<p className="text-red-600">{error}</p>
)} )}
</div> </div>
) : (
<p className="text-red-600">{error}</p>
)} )}
</div>
)} {!isLoggedIn() && (
<div className="mt-8 flex min-h-[152px] flex-col items-center justify-center gap-3 rounded-lg border border-gray-200 p-8">
{!isLoggedIn() && ( <LockIcon className="size-7 stroke-[2] text-gray-400/90" />
<div className="mt-8 flex min-h-[152px] flex-col items-center justify-center gap-3 rounded-lg border border-gray-200 p-8"> <p className="text-sm text-gray-500">
<LockIcon className="size-7 stroke-[2] text-gray-400/90" /> Please login to generate course content
<p className="text-sm text-gray-500"> </p>
Please login to generate course content </div>
</p>
</div>
)}
{!isLoading && !isGenerating && !error && (
<TestMyKnowledgeAction
courseSlug={courseSlug}
activeModuleIndex={activeModuleIndex}
activeLessonIndex={activeLessonIndex}
/>
)}
<div className="mt-8 flex items-center justify-between">
<button
onClick={onGoToPrevLesson}
disabled={cantGoBack}
className={cn(
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
cantGoBack
? 'cursor-not-allowed text-gray-400'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
)} )}
>
<ChevronLeft size={16} className="mr-2" /> {!isLoading && !isGenerating && !error && (
Previous <span className="hidden lg:inline">&nbsp;Lesson</span> <TestMyKnowledgeAction
</button> courseSlug={courseSlug}
activeModuleIndex={activeModuleIndex}
<div> activeLessonIndex={activeLessonIndex}
<button />
onClick={() => { )}
if (!isLessonDone) {
toggleDone(undefined, { <div className="mt-8 flex items-center justify-between">
onSuccess: () => { <button
onClick={onGoToPrevLesson}
disabled={cantGoBack}
className={cn(
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
cantGoBack
? 'cursor-not-allowed text-gray-400'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
)}
>
<ChevronLeft size={16} className="mr-2" />
Previous{' '}
<span className="hidden lg:inline">&nbsp;Lesson</span>
</button>
<div>
<button
onClick={() => {
if (!isLessonDone) {
toggleDone(undefined, {
onSuccess: () => {
onGoToNextLesson();
},
});
} else {
onGoToNextLesson(); onGoToNextLesson();
}, }
}); }}
} else { disabled={cantGoForward || isTogglingDone}
onGoToNextLesson(); className={cn(
} 'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
}} cantGoForward
disabled={cantGoForward || isTogglingDone} ? 'cursor-not-allowed text-gray-400'
className={cn( : 'bg-gray-800 text-white hover:bg-gray-700',
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm', )}
cantGoForward >
? 'cursor-not-allowed text-gray-400' {isTogglingDone ? (
: 'bg-gray-800 text-white hover:bg-gray-700', <>
)} <Loader2Icon
> size={16}
{isTogglingDone ? ( strokeWidth={3}
<> className="animate-spin text-white"
<Loader2Icon />
size={16} Please wait ...
strokeWidth={3} </>
className="animate-spin text-white" ) : (
/> <>
Please wait ... Next{' '}
</> <span className="hidden lg:inline">&nbsp;Lesson</span>
) : ( <ChevronRight size={16} className="ml-2" />
<> </>
Next <span className="hidden lg:inline">&nbsp;Lesson</span> )}
<ChevronRight size={16} className="ml-2" /> </button>
</> </div>
)} </div>
</button>
<AICourseFooter />
</div> </div>
</div> </div>
</ResizablePanel>
{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}
/>
</ResizablePanel>
</>
)}
<AICourseFooter /> <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}
isDisabled={isGenerating || isLoading || isTogglingDone}
/>
<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> </div>
</div> </ResizablePanelGroup>
<AICourseLessonChat
courseSlug={courseSlug}
moduleTitle={currentModuleTitle}
lessonTitle={currentLessonTitle}
onUpgradeClick={onUpgrade}
isDisabled={isGenerating || isLoading || isTogglingDone}
onClose={() => setIsAIChatsOpen(false)}
isAIChatsOpen={isAIChatsOpen}
setIsAIChatsOpen={setIsAIChatsOpen}
/>
</div> </div>
); );
} }

@ -46,25 +46,11 @@ type AICourseLessonChatProps = {
lessonTitle: string; lessonTitle: string;
onUpgradeClick: () => void; onUpgradeClick: () => void;
isDisabled?: boolean; isDisabled?: boolean;
onClose: () => void;
isAIChatsOpen: boolean;
setIsAIChatsOpen: (isAIChatsOpen: boolean) => void;
}; };
export function AICourseLessonChat(props: AICourseLessonChatProps) { export function AICourseLessonChat(props: AICourseLessonChatProps) {
const { const { courseSlug, moduleTitle, lessonTitle, onUpgradeClick, isDisabled } =
courseSlug, props;
moduleTitle,
lessonTitle,
onUpgradeClick,
isDisabled,
onClose,
isAIChatsOpen,
setIsAIChatsOpen,
} = props;
const toast = useToast(); const toast = useToast();
const scrollareaRef = useRef<HTMLDivElement | null>(null); const scrollareaRef = useRef<HTMLDivElement | null>(null);
@ -211,24 +197,8 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) {
return ( return (
<> <>
{isAIChatsOpen && ( <div className="relative h-full border-l border-gray-200">
<div
className="fixed inset-0 z-10 bg-black/50 lg:hidden"
onClick={onClose}
/>
)}
<div
className="relative col-span-2 h-full border-l border-gray-200 transition-all data-[state=closed]:hidden max-lg:fixed max-lg:inset-y-0 max-lg:right-0 max-lg:z-10 max-lg:w-[420px] max-lg:border-none max-lg:data-[state=closed]:translate-x-full max-lg:data-[state=open]:translate-x-0"
data-state={isAIChatsOpen ? 'open' : 'closed'}
>
<div className="absolute inset-y-0 right-0 z-20 flex w-full flex-col overflow-hidden bg-white"> <div className="absolute inset-y-0 right-0 z-20 flex w-full flex-col overflow-hidden bg-white">
<button
onClick={onClose}
className="absolute right-2 top-2 hidden rounded-full p-1 text-gray-400 hover:text-black max-lg:block"
>
<XIcon className="size-4 stroke-[2.5]" />
</button>
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm"> <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> <h4 className="text-base font-medium">Course AI</h4>
</div> </div>

@ -0,0 +1,42 @@
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '../../lib/classname';
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className,
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'relative flex w-px items-center justify-center bg-gray-200 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-[30px] w-3 items-center justify-center rounded-sm bg-gray-200 text-black hover:bg-gray-300">
<GripVertical className="size-5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
Loading…
Cancel
Save