feat: implement ai tutor in topics (#8546)
* wip * feat: implement ai tutor * fix: add style * feat: ai course subjects * fix: remove tree json * wip * Topic chat * Refactor topic popup * Improve UI for navigation * Update contribution URL * Improve topic popup * Update UI * feat: predefined messages * fix: ui changes * fix: add summarise * fix: add explain topic * Topic AI changes * feat: predefined message group * Refactor actions logic * Implement topic ai changes * Improve actions buttons * Add new explainer action --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>fix/creator-id
parent
2ba3e64c1c
commit
7e3508cdf4
19 changed files with 1190 additions and 306 deletions
@ -1,2 +1 @@ |
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
@ -0,0 +1,54 @@ |
||||
import type { LucideIcon } from 'lucide-react'; |
||||
import { useState, useRef } from 'react'; |
||||
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||
import { |
||||
type PredefinedActionType, |
||||
PredefinedActionButton, |
||||
} from './PredefinedActions'; |
||||
|
||||
type PredefinedActionGroupProps = { |
||||
label: string; |
||||
icon: LucideIcon; |
||||
actions: PredefinedActionType[]; |
||||
onSelect: (action: PredefinedActionType) => void; |
||||
}; |
||||
|
||||
export function PredefinedActionGroup(props: PredefinedActionGroupProps) { |
||||
const { label, icon: Icon, actions, onSelect } = props; |
||||
|
||||
const [isOpen, setIsOpen] = useState(false); |
||||
const containerRef = useRef<HTMLDivElement>(null); |
||||
|
||||
useOutsideClick(containerRef, () => { |
||||
setIsOpen(false); |
||||
}); |
||||
|
||||
return ( |
||||
<div className="relative" ref={containerRef}> |
||||
<PredefinedActionButton |
||||
label={label} |
||||
icon={Icon} |
||||
onClick={() => setIsOpen(!isOpen)} |
||||
isGroup={true} |
||||
/> |
||||
|
||||
{isOpen && ( |
||||
<div className="absolute top-full left-0 z-20 mt-1 divide-y overflow-hidden rounded-md border border-gray-200 bg-white p-0"> |
||||
{actions.map((action) => { |
||||
return ( |
||||
<PredefinedActionButton |
||||
key={action.label} |
||||
{...action} |
||||
className="py-2 pl-2.5 pr-5 w-full rounded-none bg-transparent hover:bg-gray-200" |
||||
onClick={() => { |
||||
onSelect(action); |
||||
setIsOpen(false); |
||||
}} |
||||
/> |
||||
); |
||||
})} |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,144 @@ |
||||
import { |
||||
BabyIcon, |
||||
BookOpenTextIcon, |
||||
BrainIcon, |
||||
ChevronDownIcon, |
||||
ListIcon, |
||||
NotebookPenIcon, |
||||
PencilLine, |
||||
Star, |
||||
type LucideIcon |
||||
} from 'lucide-react'; |
||||
import { cn } from '../../lib/classname'; |
||||
import { PredefinedActionGroup } from './PredefinedActionGroup'; |
||||
|
||||
export type PredefinedActionType = { |
||||
icon: LucideIcon; |
||||
label: string; |
||||
prompt?: string; |
||||
children?: PredefinedActionType[]; |
||||
}; |
||||
|
||||
export const actions: PredefinedActionType[] = [ |
||||
{ |
||||
icon: BookOpenTextIcon, |
||||
label: 'Explain', |
||||
children: [ |
||||
{ |
||||
icon: NotebookPenIcon, |
||||
label: 'Explain the topic', |
||||
prompt: 'Explain this topic in detail and include examples', |
||||
}, |
||||
{ |
||||
icon: ListIcon, |
||||
label: 'List the key points', |
||||
prompt: 'List the key points to remember from this topic', |
||||
}, |
||||
{ |
||||
icon: PencilLine, |
||||
label: 'Summarize the topic', |
||||
prompt: |
||||
'Briefly explain the topic in a few sentences. Treat it as a brief answer to an interview question. Your response should just be the answer to the question, nothing else.', |
||||
}, |
||||
{ |
||||
icon: BabyIcon, |
||||
label: 'Explain like I am five', |
||||
prompt: 'Explain this topic like I am a 5 years old', |
||||
}, |
||||
{ |
||||
icon: Star, |
||||
label: 'Why is it important?', |
||||
prompt: |
||||
'Why is this topic important? What are the real world applications (only add if appropriate)?', |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
icon: BrainIcon, |
||||
label: 'Test my Knowledge', |
||||
prompt: |
||||
"Act as an interviewer and test my understanding of this topic. Ask me a single question at a time and evaluate my answer. Question number should be bold. After evaluating my answer, immediately proceed to the next question without asking if I'm ready or want another question. Continue asking questions until I explicitly tell you to stop.", |
||||
}, |
||||
]; |
||||
|
||||
export const promptLabelMapping = actions.reduce( |
||||
(acc, action) => { |
||||
if (action.prompt) { |
||||
acc[action.prompt] = action.label; |
||||
} |
||||
|
||||
if (action.children) { |
||||
action.children.forEach((child) => { |
||||
if (child.prompt) { |
||||
acc[child.prompt] = child.label; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
return acc; |
||||
}, |
||||
{} as Record<string, string>, |
||||
); |
||||
|
||||
type PredefinedActionsProps = { |
||||
onSelect: (action: PredefinedActionType) => void; |
||||
}; |
||||
|
||||
export function PredefinedActions(props: PredefinedActionsProps) { |
||||
const { onSelect } = props; |
||||
|
||||
return ( |
||||
<div className="flex items-center gap-2 border-gray-200 px-3 py-1 text-sm"> |
||||
{actions.map((action) => { |
||||
if (!action.children) { |
||||
return ( |
||||
<PredefinedActionButton |
||||
key={action.label} |
||||
icon={action.icon} |
||||
label={action.label} |
||||
onClick={() => { |
||||
onSelect(action); |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<PredefinedActionGroup |
||||
key={action.label} |
||||
label={action.label} |
||||
icon={action.icon} |
||||
actions={action.children} |
||||
onSelect={onSelect} |
||||
/> |
||||
); |
||||
})} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
type PredefinedActionButtonProps = { |
||||
label: string; |
||||
icon?: LucideIcon; |
||||
onClick: () => void; |
||||
isGroup?: boolean; |
||||
className?: string; |
||||
}; |
||||
|
||||
export function PredefinedActionButton(props: PredefinedActionButtonProps) { |
||||
const { label, icon: Icon, onClick, isGroup = false, className } = props; |
||||
|
||||
return ( |
||||
<button |
||||
className={cn( |
||||
'flex shrink-0 items-center gap-1.5 rounded-md bg-gray-200 px-2 py-1 text-sm whitespace-nowrap hover:bg-gray-300', |
||||
className, |
||||
)} |
||||
onClick={onClick} |
||||
> |
||||
{Icon && <Icon className="mr-1 size-3.5" />} |
||||
{label} |
||||
{isGroup && <ChevronDownIcon className="size-3.5" />} |
||||
</button> |
||||
); |
||||
} |
@ -0,0 +1,475 @@ |
||||
import '../GenerateCourse/AICourseLessonChat.css'; |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
import { useState, 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, |
||||
Gift, |
||||
Loader2Icon, |
||||
LockIcon, |
||||
SendIcon, |
||||
Trash2, |
||||
} 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'; |
||||
import type { ResourceType } from '../../lib/resource-progress'; |
||||
import { getPercentage } from '../../lib/number'; |
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; |
||||
import { defaultChatHistory } from './TopicDetail'; |
||||
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup'; |
||||
import { PredefinedActions, promptLabelMapping } from './PredefinedActions'; |
||||
|
||||
type TopicDetailAIProps = { |
||||
resourceId: string; |
||||
resourceType: ResourceType; |
||||
topicId: string; |
||||
|
||||
aiChatHistory: AIChatHistoryType[]; |
||||
setAiChatHistory: (history: AIChatHistoryType[]) => void; |
||||
|
||||
onUpgrade: () => void; |
||||
onLogin: () => void; |
||||
}; |
||||
|
||||
export function TopicDetailAI(props: TopicDetailAIProps) { |
||||
const { |
||||
aiChatHistory, |
||||
setAiChatHistory, |
||||
resourceId, |
||||
resourceType, |
||||
topicId, |
||||
onUpgrade, |
||||
onLogin, |
||||
} = props; |
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null); |
||||
const scrollareaRef = useRef<HTMLDivElement>(null); |
||||
const formRef = useRef<HTMLFormElement>(null); |
||||
|
||||
const sanitizedTopicId = topicId?.includes('@') |
||||
? topicId?.split('@')?.[1] |
||||
: topicId; |
||||
|
||||
const toast = useToast(); |
||||
const [message, setMessage] = useState(''); |
||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false); |
||||
const [streamedMessage, setStreamedMessage] = useState(''); |
||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false); |
||||
const { data: tokenUsage, isLoading } = useQuery( |
||||
getAiCourseLimitOptions(), |
||||
queryClient, |
||||
); |
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } = |
||||
useQuery(billingDetailsOptions(), queryClient); |
||||
|
||||
const { data: roadmapTreeMapping, isLoading: isRoadmapTreeMappingLoading } = |
||||
useQuery( |
||||
{ |
||||
...roadmapTreeMappingOptions(resourceId), |
||||
select: (data) => { |
||||
const node = data.find( |
||||
(mapping) => mapping.nodeId === sanitizedTopicId, |
||||
); |
||||
return node; |
||||
}, |
||||
}, |
||||
queryClient, |
||||
); |
||||
|
||||
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0); |
||||
const isPaidUser = userBillingDetails?.status === 'active'; |
||||
|
||||
const handleChatSubmit = (overrideMessage?: string) => { |
||||
const trimmedMessage = (overrideMessage ?? message).trim(); |
||||
|
||||
if ( |
||||
!trimmedMessage || |
||||
isStreamingMessage || |
||||
!isLoggedIn() || |
||||
isLimitExceeded || |
||||
isLoading |
||||
) { |
||||
return; |
||||
} |
||||
|
||||
const newMessages: AIChatHistoryType[] = [ |
||||
...aiChatHistory, |
||||
{ |
||||
role: 'user', |
||||
content: trimmedMessage, |
||||
}, |
||||
]; |
||||
|
||||
flushSync(() => { |
||||
setAiChatHistory(newMessages); |
||||
setMessage(''); |
||||
}); |
||||
|
||||
scrollToBottom(); |
||||
completeAITutorChat(newMessages); |
||||
}; |
||||
|
||||
const scrollToBottom = useCallback(() => { |
||||
scrollareaRef.current?.scrollTo({ |
||||
top: scrollareaRef.current.scrollHeight, |
||||
behavior: 'smooth', |
||||
}); |
||||
}, [scrollareaRef]); |
||||
|
||||
const completeAITutorChat = async (messages: AIChatHistoryType[]) => { |
||||
try { |
||||
setIsStreamingMessage(true); |
||||
|
||||
const response = await fetch( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-topic-detail-chat`, |
||||
{ |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
}, |
||||
credentials: 'include', |
||||
body: JSON.stringify({ |
||||
resourceId, |
||||
resourceType, |
||||
topicId: sanitizedTopicId, |
||||
messages: messages.slice(-10), |
||||
}), |
||||
}, |
||||
); |
||||
|
||||
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(); |
||||
} |
||||
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions()); |
||||
return; |
||||
} |
||||
|
||||
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); |
||||
} catch (error) { |
||||
toast.error('Something went wrong'); |
||||
setIsStreamingMessage(false); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
scrollToBottom(); |
||||
}, []); |
||||
|
||||
const isDataLoading = |
||||
isLoading || isBillingDetailsLoading || isRoadmapTreeMappingLoading; |
||||
const usagePercentage = getPercentage( |
||||
tokenUsage?.used || 0, |
||||
tokenUsage?.limit || 0, |
||||
); |
||||
const hasSubjects = |
||||
roadmapTreeMapping?.subjects && roadmapTreeMapping?.subjects?.length > 0; |
||||
const hasChatHistory = aiChatHistory.length > 1; |
||||
|
||||
return ( |
||||
<div className="relative mt-4 flex grow flex-col overflow-hidden rounded-lg border border-gray-200"> |
||||
{isDataLoading && ( |
||||
<div className="absolute inset-0 z-20 flex items-center justify-center gap-2 bg-white text-black"> |
||||
<Loader2Icon className="size-8 animate-spin stroke-3 text-gray-500" /> |
||||
</div> |
||||
)} |
||||
|
||||
{showAILimitsPopup && ( |
||||
<AILimitsPopup |
||||
onClose={() => setShowAILimitsPopup(false)} |
||||
onUpgrade={() => { |
||||
setShowAILimitsPopup(false); |
||||
onUpgrade(); |
||||
}} |
||||
/> |
||||
)} |
||||
|
||||
{hasSubjects && ( |
||||
<div className="border-b border-gray-200 p-3"> |
||||
<h4 className="flex items-center gap-2 text-sm"> |
||||
Complete the following AI Tutor courses |
||||
</h4> |
||||
|
||||
<div className="mt-2.5 flex flex-wrap gap-1 text-sm"> |
||||
{roadmapTreeMapping?.subjects?.map((subject) => { |
||||
return ( |
||||
<a |
||||
key={subject} |
||||
target="_blank" |
||||
href={`/ai/search?term=${subject}&difficulty=beginner&src=topic`} |
||||
className="flex items-center gap-1 gap-2 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black" |
||||
> |
||||
{subject} |
||||
</a> |
||||
); |
||||
})} |
||||
</div> |
||||
</div> |
||||
)} |
||||
|
||||
<div |
||||
className={cn( |
||||
'flex min-h-[46px] items-center justify-between gap-2 border-gray-200 px-3 py-2 text-sm', |
||||
)} |
||||
> |
||||
{hasSubjects && ( |
||||
<span className="flex items-center gap-2 text-sm"> |
||||
<BotIcon |
||||
className="relative -top-[1px] size-4 shrink-0 text-black" |
||||
strokeWidth={2.5} |
||||
/> |
||||
<span className="hidden sm:block">Chat with AI</span> |
||||
<span className="block sm:hidden">AI Tutor</span> |
||||
</span> |
||||
)} |
||||
|
||||
{!hasSubjects && ( |
||||
<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> |
||||
)} |
||||
|
||||
{!isDataLoading && ( |
||||
<div className="flex gap-1.5"> |
||||
{hasChatHistory && ( |
||||
<button |
||||
className="rounded-md bg-white py-2 px-2 text-xs font-medium text-black hover:bg-gray-200" |
||||
onClick={() => { |
||||
setAiChatHistory(defaultChatHistory); |
||||
}} |
||||
> |
||||
<Trash2 className="size-3.5" /> |
||||
</button> |
||||
)} |
||||
|
||||
{!isPaidUser && ( |
||||
<> |
||||
<button |
||||
className="hidden rounded-md bg-gray-200 px-2 py-1 text-sm hover:bg-gray-300 sm:block" |
||||
onClick={() => { |
||||
if (!isLoggedIn()) { |
||||
onLogin(); |
||||
return; |
||||
} |
||||
|
||||
setShowAILimitsPopup(true); |
||||
}} |
||||
> |
||||
<span className="font-medium">{usagePercentage}%</span>{' '} |
||||
credits used |
||||
</button> |
||||
<button |
||||
className="flex items-center gap-1 rounded-md bg-yellow-400 px-2 py-1 text-sm text-black hover:bg-yellow-500" |
||||
onClick={() => { |
||||
if (!isLoggedIn()) { |
||||
onLogin(); |
||||
return; |
||||
} |
||||
|
||||
onUpgrade(); |
||||
}} |
||||
> |
||||
<Gift className="size-4" /> |
||||
Upgrade |
||||
</button> |
||||
</> |
||||
)} |
||||
</div> |
||||
)} |
||||
</div> |
||||
|
||||
<PredefinedActions |
||||
onSelect={(action) => { |
||||
if (!isLoggedIn()) { |
||||
onLogin(); |
||||
return; |
||||
} |
||||
|
||||
if (isLimitExceeded) { |
||||
onUpgrade(); |
||||
return; |
||||
} |
||||
|
||||
if (!action?.prompt) { |
||||
toast.error('Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
setMessage(action.prompt); |
||||
handleChatSubmit(action.prompt); |
||||
}} |
||||
/> |
||||
|
||||
<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) => { |
||||
let content = chat.content; |
||||
|
||||
if (chat.role === 'user' && promptLabelMapping[chat.content]) { |
||||
content = promptLabelMapping[chat.content]; |
||||
} |
||||
|
||||
return ( |
||||
<Fragment key={`chat-${index}`}> |
||||
<AIChatCard |
||||
role={chat.role} |
||||
content={content} |
||||
html={chat.html} |
||||
/> |
||||
</Fragment> |
||||
); |
||||
})} |
||||
|
||||
{isStreamingMessage && !streamedMessage && ( |
||||
<AIChatCard role="assistant" content="Thinking..." /> |
||||
)} |
||||
|
||||
{streamedMessage && ( |
||||
<AIChatCard role="assistant" content={streamedMessage} /> |
||||
)} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<form |
||||
ref={formRef} |
||||
className="relative flex items-start border-t border-gray-200 text-sm" |
||||
onSubmit={(e) => { |
||||
e.preventDefault(); |
||||
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={onUpgrade} |
||||
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> |
||||
)} |
||||
|
||||
{isDataLoading && ( |
||||
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white"> |
||||
<Loader2Icon className="size-4 animate-spin" /> |
||||
<p>Loading...</p> |
||||
</div> |
||||
)} |
||||
|
||||
<TextareaAutosize |
||||
className={cn( |
||||
'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-hidden', |
||||
)} |
||||
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) { |
||||
e.preventDefault(); |
||||
handleChatSubmit(); |
||||
} |
||||
}} |
||||
ref={textareaRef} |
||||
/> |
||||
<button |
||||
type="submit" |
||||
disabled={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,68 @@ |
||||
import { Earth, WandSparkles, type LucideIcon } from 'lucide-react'; |
||||
|
||||
export type AllowedTopicDetailsTabs = 'content' | 'ai'; |
||||
|
||||
type TopicDetailsTabsProps = { |
||||
activeTab: AllowedTopicDetailsTabs; |
||||
setActiveTab: (tab: AllowedTopicDetailsTabs) => void; |
||||
hasAITutor?: boolean; |
||||
}; |
||||
|
||||
export function TopicDetailsTabs(props: TopicDetailsTabsProps) { |
||||
const { activeTab, setActiveTab, hasAITutor = true } = props; |
||||
|
||||
return ( |
||||
<div className="flex w-max gap-1.5"> |
||||
<TopicDetailsTab |
||||
isActive={activeTab === 'content'} |
||||
icon={Earth} |
||||
label="Resources" |
||||
onClick={() => setActiveTab('content')} |
||||
/> |
||||
<TopicDetailsTab |
||||
isActive={activeTab === 'ai'} |
||||
icon={WandSparkles} |
||||
label="AI Tutor" |
||||
isNew={true} |
||||
isDisabled={!hasAITutor} |
||||
onClick={() => setActiveTab('ai')} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
type TopicDetailsTabProps = { |
||||
isActive: boolean; |
||||
icon: LucideIcon; |
||||
label: string; |
||||
isNew?: boolean; |
||||
isDisabled?: boolean; |
||||
onClick: () => void; |
||||
}; |
||||
|
||||
function TopicDetailsTab(props: TopicDetailsTabProps) { |
||||
const { isActive, icon: Icon, label, isNew, isDisabled, onClick } = props; |
||||
|
||||
return ( |
||||
<button |
||||
className="flex select-none disabled:pointer-events-none items-center gap-2 rounded-md border border-gray-300 px-2 py-1 text-sm text-gray-500 hover:border-gray-400 data-[state=active]:border-black data-[state=active]:bg-black data-[state=active]:text-white" |
||||
data-state={isActive ? 'active' : 'inactive'} |
||||
onClick={onClick} |
||||
disabled={isDisabled} |
||||
type="button" |
||||
> |
||||
<Icon className="h-4 w-4" /> |
||||
<span className="hidden sm:block">{label}</span> |
||||
{isNew && !isDisabled && ( |
||||
<span className="hidden rounded-sm bg-yellow-400 px-1 text-xs text-black sm:block"> |
||||
New |
||||
</span> |
||||
)} |
||||
{isDisabled && ( |
||||
<span className="hidden rounded-sm bg-gray-400 px-1 text-xs text-white sm:block"> |
||||
Soon |
||||
</span> |
||||
)} |
||||
</button> |
||||
); |
||||
} |
@ -1,10 +1,10 @@ |
||||
# How Does The Internet Work |
||||
|
||||
The Internet works through a global network of interconnected computers and servers, communicating via standardized protocols. Data is broken into packets and routed through various network nodes using the Internet Protocol (IP). These packets travel across different physical infrastructures, including fiber optic cables, satellites, and wireless networks. The Transmission Control Protocol (TCP) ensures reliable delivery and reassembly of packets at their destination. Domain Name System (DNS) servers translate human-readable website names into IP addresses. When you access a website, your device sends a request to the appropriate server, which responds with the requested data. This process, facilitated by routers, switches, and other networking equipment, enables the seamless exchange of information across vast distances, forming the backbone of our digital communications. |
||||
The internet is a global network that connects computers and devices so they can share information with each other. It’s how you browse websites, send emails, watch videos, and use apps. Think of it like a giant web that links everything together. |
||||
|
||||
Visit the following resources to learn more: |
||||
|
||||
- [@roadmap@Introduction to Internet](https://roadmap.sh/guides/what-is-internet) |
||||
- [@article@Introduction to Internet](https://roadmap.sh/guides/what-is-internet) |
||||
- [@article@How does the Internet Work?](https://cs.fyi/guide/how-does-internet-work) |
||||
- [@article@How Does the Internet Work? MDN Docs](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/How_does_the_Internet_work) |
||||
- [@video@How the Internet Works in 5 Minutes](https://www.youtube.com/watch?v=7_LPdttKXPc) |
||||
|
@ -0,0 +1,26 @@ |
||||
import { queryOptions } from '@tanstack/react-query'; |
||||
import { httpGet } from '../lib/query-http'; |
||||
|
||||
export interface RoadmapTreeDocument { |
||||
_id?: string; |
||||
roadmapId: string; |
||||
mapping: { |
||||
_id?: string; |
||||
nodeId: string; |
||||
text: string; |
||||
subjects: string[]; |
||||
}[]; |
||||
createdAt: Date; |
||||
updatedAt: Date; |
||||
} |
||||
|
||||
export function roadmapTreeMappingOptions(roadmapId: string) { |
||||
return queryOptions({ |
||||
queryKey: ['roadmap-tree-mapping', { roadmapId }], |
||||
queryFn: () => { |
||||
return httpGet<RoadmapTreeDocument['mapping']>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-roadmap-tree-mapping/${roadmapId}`, |
||||
); |
||||
}, |
||||
}); |
||||
} |
Loading…
Reference in new issue