feat/topic-chat
Arik Chakma 2 weeks ago
parent 599da5a153
commit 09ed8c4692
  1. 2
      src/components/GenerateCourse/AICourseLessonChat.tsx
  2. 410
      src/components/TopicDetail/TopicDetail.tsx
  3. 300
      src/components/TopicDetail/TopicDetailAI.tsx
  4. 53
      src/components/TopicDetail/TopicDetailsTabs.tsx

@ -399,7 +399,7 @@ type AIChatCardProps = {
html?: string;
};
function AIChatCard(props: AIChatCardProps) {
export function AIChatCard(props: AIChatCardProps) {
const { role, content, html: defaultHtml } = props;
const html = useMemo(() => {

@ -22,7 +22,16 @@ import type {
RoadmapContentDocument,
} from '../CustomRoadmap/CustomRoadmap';
import { markdownToHtml, sanitizeMarkdown } from '../../lib/markdown';
import { Ban, Coins, FileText, HeartHandshake, Star, X } from 'lucide-react';
import {
Ban,
BookIcon,
Coins,
FileText,
HeartHandshake,
SparklesIcon,
Star,
X,
} from 'lucide-react';
import { getUrlParams, parseUrl } from '../../lib/browser';
import { Spinner } from '../ReactIcons/Spinner';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
@ -33,6 +42,13 @@ import { lockBodyScroll } from '../../lib/dom.ts';
import { TopicDetailLink } from './TopicDetailLink.tsx';
import { ResourceListSeparator } from './ResourceListSeparator.tsx';
import { PaidResourceDisclaimer } from './PaidResourceDisclaimer.tsx';
import {
TopicDetailsTabs,
type AllowedTopicDetailsTabs,
} from './TopicDetailsTabs.tsx';
import { TopicDetailAI } from './TopicDetailAI.tsx';
import { cn } from '../../lib/classname.ts';
import type { AIChatHistoryType } from '../GenerateCourse/AICourseLessonChat.tsx';
type TopicDetailProps = {
resourceId?: string;
@ -53,6 +69,14 @@ type PaidResourceType = {
const paidResourcesCache: Record<string, PaidResourceType[]> = {};
const defaultChatHistory: AIChatHistoryType[] = [
{
role: 'assistant',
content: 'Hey, I am your AI instructor. How can I help you today? 🤖',
isDefault: true,
},
];
async function fetchRoadmapPaidResources(roadmapId: string) {
if (paidResourcesCache[roadmapId]) {
return paidResourcesCache[roadmapId];
@ -93,6 +117,11 @@ export function TopicDetail(props: TopicDetailProps) {
const [topicTitle, setTopicTitle] = useState('');
const [topicHtmlTitle, setTopicHtmlTitle] = useState('');
const [links, setLinks] = useState<RoadmapContentDocument['links']>([]);
const [activeTab, setActiveTab] =
useState<AllowedTopicDetailsTabs>('content');
const [aiChatHistory, setAiChatHistory] =
useState<AIChatHistoryType[]>(defaultChatHistory);
const toast = useToast();
const [showPaidResourceDisclaimer, setShowPaidResourceDisclaimer] =
@ -108,14 +137,15 @@ export function TopicDetail(props: TopicDetailProps) {
const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
const [paidResources, setPaidResources] = useState<PaidResourceType[]>([]);
// Close the topic detail when user clicks outside the topic detail
useOutsideClick(topicRef, () => {
const handleClose = () => {
setIsActive(false);
});
setAiChatHistory(defaultChatHistory);
setActiveTab('content');
};
useKeydown('Escape', () => {
setIsActive(false);
});
// Close the topic detail when user clicks outside the topic detail
useOutsideClick(topicRef, handleClose);
useKeydown('Escape', handleClose);
useEffect(() => {
if (resourceType !== 'roadmap' || !defaultResourceId) {
@ -344,7 +374,7 @@ export function TopicDetail(props: TopicDetailProps) {
<div
ref={topicRef}
tabIndex={0}
className="fixed right-0 top-0 z-40 flex h-screen w-full flex-col overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
className="fixed top-0 right-0 z-40 flex h-screen w-full flex-col overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
>
{isLoading && (
<div className="flex h-full w-full items-center justify-center">
@ -359,9 +389,12 @@ export function TopicDetail(props: TopicDetailProps) {
{!isContributing && !isLoading && !error && (
<>
<div className="flex-1">
{/* Actions for the topic */}
<div className="mb-2">
<div
className={cn('flex-1', {
'flex flex-col': activeTab === 'ai',
})}
>
<div className="mb-6">
{!isEmbed && (
<TopicProgressButton
topicId={
@ -371,200 +404,214 @@ export function TopicDetail(props: TopicDetailProps) {
}
resourceId={resourceId}
resourceType={resourceType}
onClose={() => {
setIsActive(false);
}}
onClose={handleClose}
/>
)}
<button
type="button"
id="close-topic"
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={() => {
setIsActive(false);
}}
className="absolute top-2.5 right-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={handleClose}
>
<X className="h-5 w-5" />
</button>
</div>
{/* Topic Content */}
{hasContent ? (
<>
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h1:text-balance prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
{topicTitle && <h1>{topicTitle}</h1>}
<div
id="topic-content"
dangerouslySetInnerHTML={{ __html: topicHtml }}
/>
</div>
</>
) : (
<>
{!canSubmitContribution && (
<div className="flex h-[calc(100%-38px)] flex-col items-center justify-center">
<FileText className="h-16 w-16 text-gray-300" />
<p className="mt-2 text-lg font-medium text-gray-500">
Empty Content
</p>
</div>
)}
{canSubmitContribution && (
<div className="mx-auto flex h-[calc(100%-38px)] max-w-[400px] flex-col items-center justify-center text-center">
<HeartHandshake className="mb-2 h-16 w-16 text-gray-300" />
<p className="text-lg font-semibold text-gray-900">
Help us write this content
</p>
<p className="mb-3 mt-2 text-sm text-gray-500">
Write a brief introduction to this topic and submit a
link to a good article, podcast, video, or any other
self-vetted resource that helped you understand this
topic better.
</p>
<a
href={contributionUrl}
target={'_blank'}
className="flex w-full items-center justify-center rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
>
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-white" />
Help us Write this Content
</a>
</div>
)}
</>
)}
<TopicDetailsTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
{links.length > 0 && (
<>
<ResourceListSeparator
text="Free Resources"
className="text-green-600"
icon={HeartHandshake}
/>
<ul className="ml-3 mt-4 space-y-1">
{links.map((link) => {
return (
<li key={link.id}>
<TopicDetailLink
url={link.url}
type={link.type}
title={link.title}
onClick={() => {
// if it is one of our roadmaps, we want to track the click
if (canSubmitContribution) {
const parsedUrl = parseUrl(link.url);
window.fireEvent({
category: 'TopicResourceClick',
action: `Click: ${parsedUrl.hostname}`,
label: `${resourceType} / ${resourceId} / ${topicId} / ${link.url}`,
});
}
}}
/>
</li>
);
})}
</ul>
</>
{activeTab === 'ai' && (
<TopicDetailAI
aiChatHistory={aiChatHistory}
setAiChatHistory={setAiChatHistory}
/>
)}
{paidResourcesForTopic.length > 0 && (
{activeTab === 'content' && (
<>
<ResourceListSeparator text="Premium Resources" icon={Star} />
<ul className="ml-3 mt-3 space-y-1">
{paidResourcesForTopic.map((resource) => {
return (
<li key={resource._id}>
<TopicDetailLink
url={resource.url}
type={resource.type as any}
title={resource.title}
isPaid={true}
/>
</li>
);
})}
</ul>
{hasPaidScrimbaLinks && (
<div className="relative -mb-1 ml-3 mt-4 rounded-md border border-yellow-300 bg-yellow-100 px-2.5 py-2 text-sm text-yellow-800">
<div className="flex items-center gap-2">
<Coins className="h-4 w-4 text-yellow-700" />
<span>
Scrimba is offering{' '}
<span className={'font-semibold'}>20% off</span> on
all courses for roadmap.sh users.
</span>
{hasContent ? (
<>
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h1:text-balance prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
{topicTitle && <h1>{topicTitle}</h1>}
<div
id="topic-content"
dangerouslySetInnerHTML={{ __html: topicHtml }}
/>
</div>
</div>
</>
) : (
<>
{!canSubmitContribution && (
<div className="flex h-[calc(100%-38px)] flex-col items-center justify-center">
<FileText className="h-16 w-16 text-gray-300" />
<p className="mt-2 text-lg font-medium text-gray-500">
Empty Content
</p>
</div>
)}
{canSubmitContribution && (
<div className="mx-auto flex h-[calc(100%-38px)] max-w-[400px] flex-col items-center justify-center text-center">
<HeartHandshake className="mb-2 h-16 w-16 text-gray-300" />
<p className="text-lg font-semibold text-gray-900">
Help us write this content
</p>
<p className="mt-2 mb-3 text-sm text-gray-500">
Write a brief introduction to this topic and submit
a link to a good article, podcast, video, or any
other self-vetted resource that helped you
understand this topic better.
</p>
<a
href={contributionUrl}
target={'_blank'}
className="flex w-full items-center justify-center rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
>
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-white" />
Help us Write this Content
</a>
</div>
)}
</>
)}
{showPaidResourceDisclaimer && (
<PaidResourceDisclaimer
onClose={() => {
localStorage.setItem(
PAID_RESOURCE_DISCLAIMER_HIDDEN,
'true',
);
setShowPaidResourceDisclaimer(false);
}}
/>
{links.length > 0 && (
<>
<ResourceListSeparator
text="Free Resources"
className="text-green-600"
icon={HeartHandshake}
/>
<ul className="mt-4 ml-3 space-y-1">
{links.map((link) => {
return (
<li key={link.id}>
<TopicDetailLink
url={link.url}
type={link.type}
title={link.title}
onClick={() => {
// if it is one of our roadmaps, we want to track the click
if (canSubmitContribution) {
const parsedUrl = parseUrl(link.url);
window.fireEvent({
category: 'TopicResourceClick',
action: `Click: ${parsedUrl.hostname}`,
label: `${resourceType} / ${resourceId} / ${topicId} / ${link.url}`,
});
}
}}
/>
</li>
);
})}
</ul>
</>
)}
</>
)}
{/* Contribution */}
{canSubmitContribution &&
!hasEnoughLinks &&
contributionUrl &&
hasContent && (
<div className="mb-12 mt-3 border-t text-sm text-gray-400 sm:mt-12">
<div className="mb-4 mt-3">
<p className="">
Find more resources using these pre-filled search
queries:
</p>
<div className="mt-3 flex gap-2 text-gray-700">
<a
href={googleSearchUrl}
target="_blank"
className="flex items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 pl-2 text-xs hover:border-gray-700 hover:bg-gray-100"
>
<GoogleIcon className={'h-4 w-4'} />
Google
</a>
{paidResourcesForTopic.length > 0 && (
<>
<ResourceListSeparator
text="Premium Resources"
icon={Star}
/>
<ul className="mt-3 ml-3 space-y-1">
{paidResourcesForTopic.map((resource) => {
return (
<li key={resource._id}>
<TopicDetailLink
url={resource.url}
type={resource.type as any}
title={resource.title}
isPaid={true}
/>
</li>
);
})}
</ul>
{hasPaidScrimbaLinks && (
<div className="relative mt-4 -mb-1 ml-3 rounded-md border border-yellow-300 bg-yellow-100 px-2.5 py-2 text-sm text-yellow-800">
<div className="flex items-center gap-2">
<Coins className="h-4 w-4 text-yellow-700" />
<span>
Scrimba is offering{' '}
<span className={'font-semibold'}>20% off</span>{' '}
on all courses for roadmap.sh users.
</span>
</div>
</div>
)}
{showPaidResourceDisclaimer && (
<PaidResourceDisclaimer
onClose={() => {
localStorage.setItem(
PAID_RESOURCE_DISCLAIMER_HIDDEN,
'true',
);
setShowPaidResourceDisclaimer(false);
}}
/>
)}
</>
)}
{canSubmitContribution &&
!hasEnoughLinks &&
contributionUrl &&
hasContent && (
<div className="mt-3 mb-12 border-t text-sm text-gray-400 sm:mt-12">
<div className="mt-3 mb-4">
<p className="">
Find more resources using these pre-filled search
queries:
</p>
<div className="mt-3 flex gap-2 text-gray-700">
<a
href={googleSearchUrl}
target="_blank"
className="flex items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 pl-2 text-xs hover:border-gray-700 hover:bg-gray-100"
>
<GoogleIcon className={'h-4 w-4'} />
Google
</a>
<a
href={youtubeSearchUrl}
target="_blank"
className="flex items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 pl-2 text-xs hover:border-gray-700 hover:bg-gray-100"
>
<YouTubeIcon className={'h-4 w-4 text-red-500'} />
YouTube
</a>
</div>
</div>
<p className="mt-2 mb-2 leading-relaxed">
This popup should be a brief introductory paragraph
for the topic and a few links to good articles,
videos, or any other self-vetted resources. Please
consider submitting a PR to improve this content.
</p>
<a
href={youtubeSearchUrl}
target="_blank"
className="flex items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 pl-2 text-xs hover:border-gray-700 hover:bg-gray-100"
href={contributionUrl}
target={'_blank'}
className="flex w-full items-center justify-center rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
>
<YouTubeIcon className={'h-4 w-4 text-red-500'} />
YouTube
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-white" />
Help us Improve this Content
</a>
</div>
</div>
<p className="mb-2 mt-2 leading-relaxed">
This popup should be a brief introductory paragraph for
the topic and a few links to good articles, videos, or any
other self-vetted resources. Please consider submitting a
PR to improve this content.
</p>
<a
href={contributionUrl}
target={'_blank'}
className="flex w-full items-center justify-center rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
>
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-white" />
Help us Improve this Content
</a>
</div>
)}
)}
</>
)}
</div>
{resourceId === 'devops' && (
{resourceId === 'devops' && activeTab === 'content' && (
<div className="mt-4">
<a
href={tnsLink}
@ -602,13 +649,12 @@ export function TopicDetail(props: TopicDetailProps) {
</>
)}
{/* Error */}
{!isContributing && !isLoading && error && (
<>
<button
type="button"
id="close-topic"
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
className="absolute top-2.5 right-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={() => {
setIsActive(false);
setIsContributing(false);

@ -0,0 +1,300 @@
import { useQuery } from '@tanstack/react-query';
import {
useState,
type FormEvent,
useRef,
Fragment,
useCallback,
useEffect,
} from 'react';
import { billingDetailsOptions } from '../../queries/billing';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { BotIcon, LockIcon, SendIcon } from 'lucide-react';
import { showLoginPopup } from '../../lib/popup';
import { cn } from '../../lib/classname';
import TextareaAutosize from 'react-textarea-autosize';
import { flushSync } from 'react-dom';
import {
AIChatCard,
type AIChatHistoryType,
} from '../GenerateCourse/AICourseLessonChat';
import { useToast } from '../../hooks/use-toast';
import { readStream } from '../../lib/ai';
import { markdownToHtmlWithHighlighting } from '../../lib/markdown';
type TopicDetailAIProps = {
aiChatHistory: AIChatHistoryType[];
setAiChatHistory: (history: AIChatHistoryType[]) => void;
};
export function TopicDetailAI(props: TopicDetailAIProps) {
const { aiChatHistory, setAiChatHistory } = props;
const scrollareaRef = useRef<HTMLDivElement>(null);
const toast = useToast();
const [message, setMessage] = useState('');
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] = useState('');
const { data: tokenUsage, isLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmedMessage = message.trim();
if (
!trimmedMessage ||
isStreamingMessage ||
!isLoggedIn() ||
isLimitExceeded ||
isLoading
) {
return;
}
const newMessages: AIChatHistoryType[] = [
...aiChatHistory,
{
role: 'user',
content: trimmedMessage,
},
];
flushSync(() => {
setAiChatHistory(newMessages);
setMessage('');
});
scrollToBottom();
// completeCourseAIChat(newMessages);
};
const scrollToBottom = useCallback(() => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior: 'smooth',
});
}, [scrollareaRef]);
const completeCourseAIChat = async (messages: AIChatHistoryType[]) => {
setIsStreamingMessage(true);
// const response = await fetch(
// `${import.meta.env.PUBLIC_API_URL}/v1-follow-up-ai-course/${courseSlug}`,
// {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// credentials: 'include',
// body: JSON.stringify({
// moduleTitle,
// lessonTitle,
// messages: messages.slice(-10),
// }),
// },
// );
const response = new Response();
if (!response.ok) {
const data = await response.json();
toast.error(data?.message || 'Something went wrong');
setAiChatHistory([...messages].slice(0, messages.length - 1));
setIsStreamingMessage(false);
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
}
const reader = response.body?.getReader();
if (!reader) {
setIsStreamingMessage(false);
toast.error('Something went wrong');
return;
}
await readStream(reader, {
onStream: async (content) => {
flushSync(() => {
setStreamedMessage(content);
});
scrollToBottom();
},
onStreamEnd: async (content) => {
const newMessages: AIChatHistoryType[] = [
...messages,
{
role: 'assistant',
content,
html: await markdownToHtmlWithHighlighting(content),
},
];
flushSync(() => {
setStreamedMessage('');
setIsStreamingMessage(false);
setAiChatHistory(newMessages);
});
queryClient.invalidateQueries(getAiCourseLimitOptions());
scrollToBottom();
},
});
setIsStreamingMessage(false);
};
useEffect(() => {
scrollToBottom();
}, []);
return (
<div className="mt-4 flex grow flex-col rounded-lg border">
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm">
<h4 className="flex items-center gap-2 text-base font-medium">
<BotIcon
className="relative -top-[1px] size-5 shrink-0 text-black"
strokeWidth={2.5}
/>
AI Tutor
</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="relative flex grow flex-col justify-end">
<div className="flex flex-col justify-end gap-2 px-3 py-2">
{aiChatHistory.map((chat, index) => {
return (
<Fragment key={`chat-${index}`}>
<AIChatCard
role={chat.role}
content={chat.content}
html={chat.html}
/>
{/* {chat.isDefault && defaultQuestions?.length > 1 && (
<div className="mt-0.5 mb-1">
<p className="mb-2 text-xs font-normal text-gray-500">
Some questions you might have about this lesson.
</p>
<div className="flex flex-col justify-end gap-1">
{defaultQuestions.map((question, index) => (
<button
key={`default-question-${index}`}
className="flex h-full self-start rounded-md bg-yellow-500/10 px-3 py-2 text-left text-sm text-black hover:bg-yellow-500/20"
onClick={() => {
flushSync(() => {
setMessage(question);
});
textareaRef.current?.focus();
}}
>
{question}
</button>
))}
</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 && isLoggedIn() && (
<div className="absolute inset-0 z-10 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>
)}
{!isLoggedIn() && (
<div className="absolute inset-0 z-10 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">Please login to continue</p>
<button
onClick={() => {
showLoginPopup();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Login / Register
</button>
</div>
)}
<TextareaAutosize
className={cn(
'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-hidden',
// 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"
>
<SendIcon className="size-4 stroke-[2.5]" />
</button>
</form>
</div>
);
}

@ -0,0 +1,53 @@
import { BookIcon, SparklesIcon, type LucideIcon } from 'lucide-react';
export type AllowedTopicDetailsTabs = 'content' | 'ai';
type TopicDetailsTabsProps = {
activeTab: AllowedTopicDetailsTabs;
setActiveTab: (tab: AllowedTopicDetailsTabs) => void;
};
export function TopicDetailsTabs(props: TopicDetailsTabsProps) {
const { activeTab, setActiveTab } = props;
return (
<div className="flex w-max items-center gap-1 rounded-lg border border-gray-200 p-0.5">
<TopicDetailsTab
isActive={activeTab === 'content'}
icon={BookIcon}
label="Content"
onClick={() => setActiveTab('content')}
/>
<TopicDetailsTab
isActive={activeTab === 'ai'}
icon={SparklesIcon}
label="Learn with AI"
onClick={() => setActiveTab('ai')}
/>
</div>
);
}
type TopicDetailsTabProps = {
isActive: boolean;
icon: LucideIcon;
label: string;
onClick: () => void;
};
function TopicDetailsTab(props: TopicDetailsTabProps) {
const { isActive, icon: Icon, label, onClick } = props;
return (
<button
className="flex h-7 items-center gap-2 rounded-md px-2 py-0.5 text-sm text-gray-500 data-[state=active]:bg-black data-[state=active]:text-white"
data-state={isActive ? 'active' : 'inactive'}
onClick={onClick}
disabled={isActive}
type="button"
>
<Icon className="h-4 w-4" />
{label}
</button>
);
}
Loading…
Cancel
Save