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
Arik Chakma 7 days ago committed by GitHub
parent 2ba3e64c1c
commit 7e3508cdf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      .astro/types.d.ts
  2. 1
      src/components/CustomRoadmap/CustomRoadmap.tsx
  3. 2
      src/components/GenerateCourse/AICourseLessonChat.tsx
  4. 16
      src/components/GenerateCourse/GenerateAICourse.tsx
  5. 54
      src/components/TopicDetail/PredefinedActionGroup.tsx
  6. 144
      src/components/TopicDetail/PredefinedActions.tsx
  7. 2
      src/components/TopicDetail/ResourceListSeparator.tsx
  8. 462
      src/components/TopicDetail/TopicDetail.tsx
  9. 475
      src/components/TopicDetail/TopicDetailAI.tsx
  10. 48
      src/components/TopicDetail/TopicDetailLink.tsx
  11. 68
      src/components/TopicDetail/TopicDetailsTabs.tsx
  12. 177
      src/components/TopicDetail/TopicProgressButton.tsx
  13. 4
      src/data/roadmaps/frontend/content/how-does-the-internet-work@yCnn-NfSxIybUQ2iTuUGq.md
  14. 2
      src/data/roadmaps/frontend/content/internet@VlNNwIEDWqQXtqkHWJYzC.md
  15. 3
      src/helper/generate-ai-course.ts
  16. 4
      src/hooks/use-keydown.ts
  17. 3
      src/pages/[roadmapId]/index.astro
  18. 4
      src/pages/best-practices/[bestPracticeId]/index.astro
  19. 26
      src/queries/roadmap-tree.ts

1
.astro/types.d.ts vendored

@ -1,2 +1 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />

@ -118,6 +118,7 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
resourceId={roadmap!._id}
resourceTitle={roadmap!.title}
resourceType="roadmap"
renderer='editor'
isEmbed={isEmbed}
canSubmitContribution={false}
/>

@ -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(() => {

@ -54,6 +54,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
const params = getUrlParams();
const paramsTerm = params?.term;
const paramsDifficulty = params?.difficulty;
const paramsSrc = params?.src || 'search';
if (!paramsTerm || !paramsDifficulty) {
return;
}
@ -87,6 +88,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
instructions: paramsCustomInstructions,
goal: paramsGoal,
about: paramsAbout,
src: paramsSrc,
});
}, [term, difficulty]);
@ -98,9 +100,18 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
about?: string;
isForce?: boolean;
prompt?: string;
src?: string;
}) => {
const { term, difficulty, isForce, prompt, instructions, goal, about } =
options;
const {
term,
difficulty,
isForce,
prompt,
instructions,
goal,
about,
src,
} = options;
if (!isLoggedIn()) {
window.location.href = '/ai';
@ -121,6 +132,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
about,
isForce,
prompt,
src,
});
};

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

@ -27,7 +27,7 @@ export function ResourceListSeparator(props: ResourceSeparatorProps) {
{Icon && <Icon className="inline-block h-3 w-3 fill-current" />}
{text}
</span>
<hr className="absolute inset-x-0 grow border-current" />
<span className="absolute inset-x-0 h-px w-full grow bg-current" />
</p>
);
}

@ -14,7 +14,6 @@ import {
updateResourceProgress as updateResourceProgressApi,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { TopicProgressButton } from './TopicProgressButton';
import { showLoginPopup } from '../../lib/popup';
import { useToast } from '../../hooks/use-toast';
import type {
@ -22,20 +21,33 @@ import type {
RoadmapContentDocument,
} from '../CustomRoadmap/CustomRoadmap';
import { markdownToHtml, sanitizeMarkdown } from '../../lib/markdown';
import { Ban, Coins, FileText, HeartHandshake, Star, X } from 'lucide-react';
import { Ban, FileText, HeartHandshake, Star, X } from 'lucide-react';
import { getUrlParams, parseUrl } from '../../lib/browser';
import { Spinner } from '../ReactIcons/Spinner';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import { resourceTitleFromId } from '../../lib/roadmap.ts';
import {
resourceTitleFromId,
type AllowedRoadmapRenderer,
} from '../../lib/roadmap.ts';
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';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal.tsx';
import { TopicProgressButton } from './TopicProgressButton.tsx';
type TopicDetailProps = {
resourceId?: string;
resourceTitle?: string;
resourceType?: ResourceType;
renderer?: AllowedRoadmapRenderer;
isEmbed?: boolean;
canSubmitContribution: boolean;
@ -51,6 +63,14 @@ type PaidResourceType = {
const paidResourcesCache: Record<string, PaidResourceType[]> = {};
export 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];
@ -77,6 +97,7 @@ export function TopicDetail(props: TopicDetailProps) {
canSubmitContribution,
resourceId: defaultResourceId,
isEmbed = false,
renderer = 'balsamiq',
resourceTitle,
} = props;
@ -91,6 +112,13 @@ 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 [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isCustomResource, setIsCustomResource] = useState(false);
const toast = useToast();
const [showPaidResourceDisclaimer, setShowPaidResourceDisclaimer] =
@ -106,14 +134,16 @@ 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);
});
setShowUpgradeModal(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) {
@ -177,6 +207,7 @@ export function TopicDetail(props: TopicDetailProps) {
setTopicId(topicId);
setResourceType(resourceType);
setResourceId(resourceId);
setIsCustomResource(isCustomResource);
const topicPartial = topicId.replaceAll(':', '/');
let topicUrl =
@ -335,15 +366,21 @@ export function TopicDetail(props: TopicDetailProps) {
(resource) => resource?.url?.toLowerCase().indexOf('scrimba') !== -1,
);
const shouldShowAiTab = !isCustomResource && resourceType === 'roadmap';
return (
<div className={'relative z-92'}>
<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"
>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
{isLoading && (
<div className="flex h-full w-full items-center justify-center">
<div className="flex h-full w-full justify-center">
<Spinner
outerFill="#d1d5db"
className="h-6 w-6 sm:h-8 sm:w-8"
@ -355,234 +392,229 @@ export function TopicDetail(props: TopicDetailProps) {
{!isContributing && !isLoading && !error && (
<>
<div className="flex-1">
{/* Actions for the topic */}
<div className="mb-2">
{!isEmbed && (
<TopicProgressButton
topicId={
topicId.indexOf('@') !== -1
? topicId.split('@')[1]
: topicId
}
resourceId={resourceId}
resourceType={resourceType}
onClose={() => {
setIsActive(false);
}}
<div
className={cn('flex-1', {
'flex flex-col': activeTab === 'ai',
})}
>
<div className="flex justify-between">
{shouldShowAiTab && (
<TopicDetailsTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
hasAITutor={renderer === 'editor'}
/>
)}
<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);
}}
<div
className={cn('flex flex-grow justify-end gap-1', {
'justify-between': !shouldShowAiTab,
})}
>
<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 }}
{!isEmbed && (
<TopicProgressButton
topicId={
topicId.indexOf('@') !== -1
? topicId.split('@')[1]
: topicId
}
dropdownClassName={
!shouldShowAiTab ? 'left-0' : 'right-0'
}
resourceId={resourceId}
resourceType={resourceType}
onClose={handleClose}
/>
</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>
)}
</>
)}
<button
type="button"
id="close-topic"
className="flex items-center gap-1.5 rounded-lg bg-gray-200 px-1.5 py-1 text-xs text-black hover:bg-gray-300 hover:text-gray-900"
onClick={handleClose}
>
<X className="size-4" />
</button>
</div>
</div>
{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' && shouldShowAiTab && (
<TopicDetailAI
resourceId={resourceId}
resourceType={resourceType}
topicId={topicId}
aiChatHistory={aiChatHistory}
setAiChatHistory={setAiChatHistory}
onUpgrade={() => setShowUpgradeModal(true)}
onLogin={() => {
handleClose();
showLoginPopup();
}}
/>
)}
{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>
</>
)}
{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>
{showPaidResourceDisclaimer && (
<PaidResourceDisclaimer
onClose={() => {
localStorage.setItem(
PAID_RESOURCE_DISCLAIMER_HIDDEN,
'true',
);
setShowPaidResourceDisclaimer(false);
}}
/>
)}
</>
)}
</>
)}
{/* Contribution */}
{canSubmitContribution &&
!hasEnoughLinks &&
contributionUrl &&
hasContent && (
<div className="mb-12 mt-3 border-t text-sm text-gray-400 sm:mt-12">
<h2 className="mb-1 mt-4 text-base text-black font-medium">
Help us add learning resources
</h2>
<p className="mb-4 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 learning 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' && (
<div className="mt-4">
<a
href={tnsLink}
target="_blank"
className="hidden rounded-md border bg-gray-200 px-2 py-2 text-sm hover:bg-gray-300 sm:block"
>
<span className="badge mr-1.5">Partner</span>
Get the latest {resourceTitleFromId(resourceId)} news from our
sister site{' '}
<span className="font-medium underline underline-offset-1">
TheNewStack.io
</span>
</a>
<a
href={tnsLink}
className="hidden rounded-md border bg-gray-200 px-2 py-1.5 text-sm hover:bg-gray-300 min-[390px]:block sm:hidden"
onClick={() => {
window.fireEvent({
category: 'PartnerClick',
action: 'TNS Redirect',
label: 'Roadmap Topic / TNS Link',
});
}}
>
<span className="badge mr-1.5">Partner</span>
Visit{' '}
<span className="font-medium underline underline-offset-1">
TheNewStack.io
</span>{' '}
for {resourceTitleFromId(resourceId)} news
</a>
</div>
)}
{canSubmitContribution &&
contributionUrl &&
activeTab === 'content' &&
hasContent && (
<div className="mt-4">
<a
href={contributionUrl}
target="_blank"
className="hidden items-center justify-center rounded-md border bg-gray-200 px-2 py-2 text-sm hover:bg-gray-300 sm:flex"
>
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-current" />
Help us Improve this Content
</a>
<a
href={tnsLink}
className="hidden rounded-md border bg-gray-200 px-2 py-1.5 text-sm hover:bg-gray-300 min-[390px]:block sm:hidden"
onClick={() => {
window.fireEvent({
category: 'PartnerClick',
action: 'TNS Redirect',
label: 'Roadmap Topic / TNS Link',
});
}}
>
<span className="badge mr-1.5">Partner</span>
Visit{' '}
<span className="font-medium underline underline-offset-1">
TheNewStack.io
</span>{' '}
for {resourceTitleFromId(resourceId)} news
</a>
</div>
)}
</>
)}
{/* 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,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>
);
}

@ -1,7 +1,7 @@
import { cn } from '../../lib/classname.ts';
import type { AllowedLinkTypes } from '../CustomRoadmap/CustomRoadmap.tsx';
const linkTypes: Record<AllowedLinkTypes, string> = {
const linkTypes: Record<AllowedLinkTypes | string, string> = {
article: 'bg-yellow-300',
course: 'bg-green-400',
opensource: 'bg-black text-white',
@ -18,6 +18,34 @@ const paidLinkTypes: Record<string, string> = {
course: 'bg-yellow-300',
};
type TopicLinkBadgeProps = {
isPaid: boolean;
discountText?: string;
type: AllowedLinkTypes | string;
className?: string;
};
function TopicLinkBadge(props: TopicLinkBadgeProps) {
const { isPaid, type, className } = props;
const linkType = type === 'opensource' ? 'OpenSource' : type;
const isDiscount = type.includes('% off');
return (
<span className={cn('mr-2', className)}>
<span
className={cn(
'inline-block rounded-sm px-1.5 py-0.5 text-xs capitalize no-underline',
(isPaid ? paidLinkTypes[type] : linkTypes[type]) || 'bg-gray-200',
isDiscount && 'bg-green-300',
)}
>
{linkType}
</span>
</span>
);
}
type TopicDetailLinkProps = {
url: string;
onClick?: () => void;
@ -29,7 +57,7 @@ type TopicDetailLinkProps = {
export function TopicDetailLink(props: TopicDetailLinkProps) {
const { url, onClick, type, title, isPaid = false } = props;
const linkType = type === 'opensource' ? 'OpenSource' : type;
const isScrimbaLink = url.toLowerCase().includes('scrimba.com');
return (
<a
@ -38,14 +66,14 @@ export function TopicDetailLink(props: TopicDetailLinkProps) {
className="group font-medium text-gray-800 underline underline-offset-1 hover:text-black"
onClick={onClick}
>
<span
className={cn(
'mr-2 inline-block rounded-sm px-1.5 py-0.5 text-xs capitalize no-underline',
(isPaid ? paidLinkTypes[type] : linkTypes[type]) || 'bg-gray-200',
)}
>
{linkType}
</span>
<TopicLinkBadge
isPaid={isPaid}
type={type}
discountText={isScrimbaLink ? '20% off' : undefined}
className={isScrimbaLink ? 'mr-1' : 'mr-2'}
/>
{isScrimbaLink && <TopicLinkBadge isPaid={isPaid} type="20% off" />}
{title}
</a>
);

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

@ -16,25 +16,54 @@ import { showLoginPopup } from '../../lib/popup';
import { useToast } from '../../hooks/use-toast';
import { Spinner } from '../ReactIcons/Spinner';
import { ChevronDown } from 'lucide-react';
import { cn } from '../../lib/classname';
const statusColors: Record<ResourceProgressType, string> = {
done: 'bg-green-500',
learning: 'bg-yellow-500',
pending: 'bg-gray-300',
skipped: 'bg-black',
removed: '',
};
type TopicProgressButtonProps = {
topicId: string;
resourceId: string;
resourceType: ResourceType;
dropdownClassName?: string;
onClose: () => void;
};
const statusColors: Record<ResourceProgressType, string> = {
done: 'bg-green-500',
learning: 'bg-yellow-500',
pending: 'bg-gray-300',
skipped: 'bg-black',
removed: '',
type ProgressDropdownItemProps = {
status: ResourceProgressType;
shortcutKey: string;
label: string;
onClick: () => void;
};
function ProgressDropdownItem(props: ProgressDropdownItemProps) {
const { status, shortcutKey, label, onClick } = props;
return (
<button
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
onClick={onClick}
>
<span>
<span
className={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors[status]}`}
></span>
{label}
</span>
<span className="text-xs text-gray-500">{shortcutKey}</span>
</button>
);
}
export function TopicProgressButton(props: TopicProgressButtonProps) {
const { topicId, resourceId, resourceType, onClose } = props;
const { topicId, resourceId, resourceType, onClose, dropdownClassName } =
props;
const toast = useToast();
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
@ -66,7 +95,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as done
useKeydown(
'd',
() => {
(e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'done') {
onClose();
return;
@ -80,7 +117,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as learning
useKeydown(
'l',
() => {
(e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'learning') {
return;
}
@ -93,7 +138,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as learning
useKeydown(
's',
() => {
(e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'skipped') {
onClose();
return;
@ -107,9 +160,16 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as pending
useKeydown(
'r',
() => {
(e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'pending') {
onClose();
return;
}
@ -147,6 +207,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
console.error(err);
})
.finally(() => {
setShowChangeStatus(false);
setIsUpdatingProgress(false);
});
};
@ -167,15 +228,20 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
if (isUpdatingProgress) {
return (
<button className="inline-flex cursor-default items-center rounded-md border border-gray-300 bg-white p-1 px-2 text-sm text-black">
<Spinner className="h-4 w-4" />
<span className="ml-2">Updating Status..</span>
<Spinner isDualRing={false} className="h-4 w-4" />
<span className="ml-2">Please wait..</span>
</button>
);
}
return (
<div className="relative inline-flex rounded-md border border-gray-300">
<span className="inline-flex cursor-default items-center p-1 px-2 text-sm text-black">
<div className="relative inline-flex">
<button
className={cn(
'flex cursor-default cursor-pointer items-center rounded-md border border-gray-300 p-1 px-2 text-sm text-black hover:border-gray-400',
)}
onClick={() => setShowChangeStatus(true)}
>
<span className="flex h-2 w-2">
<span
className={`relative inline-flex h-2 w-2 rounded-full ${statusColors[progress]}`}
@ -184,77 +250,48 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
<span className="ml-2 capitalize">
{progress === 'learning' ? 'In Progress' : progress}
</span>
</span>
<button
className="inline-flex cursor-pointer items-center rounded-br-md rounded-tr-md border-l border-l-gray-300 bg-gray-100 p-1 px-2 text-sm text-black hover:bg-gray-200"
onClick={() => setShowChangeStatus(true)}
>
<span className="mr-0.5">Update Status</span>
<ChevronDown className="h-4 w-4" />
<ChevronDown className="ml-2 h-4 w-4" />
</button>
{showChangeStatus && (
<div
className="absolute right-0 top-full mt-1 flex min-w-[160px] flex-col divide-y rounded-md border border-gray-200 bg-white shadow-md [&>button:first-child]:rounded-t-md [&>button:last-child]:rounded-b-md"
className={cn(
'absolute top-full right-0 z-50 mt-1 flex min-w-[160px] flex-col divide-y rounded-md border border-gray-200 bg-white shadow-md [&>button:first-child]:rounded-t-md [&>button:last-child]:rounded-b-md',
dropdownClassName,
)}
ref={changeStatusRef!}
>
{allowMarkingDone && (
<button
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
<ProgressDropdownItem
status="done"
shortcutKey="D"
label="Done"
onClick={() => handleUpdateResourceProgress('done')}
>
<span>
<span
className={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['done']}`}
></span>
Done
</span>
<span className="text-xs text-gray-500">D</span>
</button>
/>
)}
{allowMarkingLearning && (
<button
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
<ProgressDropdownItem
status="learning"
shortcutKey="L"
label="In Progress"
onClick={() => handleUpdateResourceProgress('learning')}
>
<span>
<span
className={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['learning']}`}
></span>
In Progress
</span>
<span className="text-xs text-gray-500">L</span>
</button>
/>
)}
{allowMarkingPending && (
<button
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
<ProgressDropdownItem
status="pending"
shortcutKey="R"
label="Reset"
onClick={() => handleUpdateResourceProgress('pending')}
>
<span>
<span
className={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['pending']}`}
></span>
Reset
</span>
<span className="text-xs text-gray-500">R</span>
</button>
/>
)}
{allowMarkingSkipped && (
<button
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
<ProgressDropdownItem
status="skipped"
shortcutKey="S"
label="Skip"
onClick={() => handleUpdateResourceProgress('skipped')}
>
<span>
<span
className={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['skipped']}`}
></span>
Skip
</span>
<span className="text-xs text-gray-500">S</span>
</button>
/>
)}
</div>
)}

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

@ -4,5 +4,5 @@ The Internet is a global network of interconnected computer networks that use th
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@The Internet](https://en.wikipedia.org/wiki/Internet)

@ -20,6 +20,7 @@ type GenerateCourseOptions = {
onCourseChange?: (course: AiCourse, rawData: string) => void;
onLoadingChange?: (isLoading: boolean) => void;
onError?: (error: string) => void;
src?: string;
};
export async function generateCourse(options: GenerateCourseOptions) {
@ -37,6 +38,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
instructions,
goal,
about,
src = 'search',
} = options;
onLoadingChange?.(true);
@ -85,6 +87,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
instructions,
goal,
about,
src,
}),
credentials: 'include',
},

@ -7,14 +7,14 @@ export function useKeydown(keyName: string, callback: any, deps: any[] = []) {
!keyName.startsWith('mod_') &&
event.key.toLowerCase() === keyName.toLowerCase()
) {
callback();
callback(event);
} else if (
keyName.startsWith('mod_') &&
event.metaKey &&
event.key.toLowerCase() === keyName.replace('mod_', '').toLowerCase()
) {
event.preventDefault();
callback();
callback(event);
}
};

@ -18,6 +18,7 @@ import RoadmapNote from '../../components/RoadmapNote.astro';
import { RoadmapTitleQuestion } from '../../components/RoadmapTitleQuestion';
import ResourceProgressStats from '../../components/ResourceProgressStats.astro';
import { getProjectsByRoadmapId } from '../../lib/project';
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
export const prerender = true;
@ -101,6 +102,7 @@ const courses = roadmapData.courses || [];
resourceTitle={roadmapData.title}
resourceId={roadmapId}
resourceType='roadmap'
renderer={roadmapData.renderer}
client:idle
canSubmitContribution={true}
/>
@ -183,5 +185,6 @@ const courses = roadmapData.courses || [];
<RelatedRoadmaps roadmap={roadmapData} />
</div>
<CheckSubscriptionVerification client:load />
<div slot='changelog-banner'></div>
</BaseLayout>

@ -14,6 +14,7 @@ import {
type BestPracticeFrontmatter,
getAllBestPractices,
} from '../../../lib/best-practice';
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
export const prerender = true;
@ -107,6 +108,7 @@ const ogImageUrl = getOpenGraphImageUrl({
resourceId={bestPracticeId}
resourceTitle={bestPracticeData.title}
resourceType='best-practice'
renderer={'balsamiq'}
client:idle
canSubmitContribution={true}
/>
@ -136,6 +138,6 @@ const ogImageUrl = getOpenGraphImageUrl({
/>
{bestPracticeData.isUpcoming && <UpcomingForm />}
<CheckSubscriptionVerification client:load />
<div slot='changelog-banner'></div>
</BaseLayout>

@ -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…
Cancel
Save