fix: fixed position

feat/chat
Arik Chakma 3 weeks ago
parent 9baf891b2b
commit 496a77ebbe
  1. 30
      src/components/GenerateCourse/AICourseContent.tsx
  2. 62
      src/components/GenerateCourse/AICourseLesson.tsx
  3. 278
      src/components/GenerateCourse/AICourseLessonChat.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)}

@ -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>

@ -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>
);
}

Loading…
Cancel
Save