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 1 week 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 types="astro/client" />
/// <reference path="content.d.ts" />

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

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

@ -54,6 +54,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
const params = getUrlParams(); const params = getUrlParams();
const paramsTerm = params?.term; const paramsTerm = params?.term;
const paramsDifficulty = params?.difficulty; const paramsDifficulty = params?.difficulty;
const paramsSrc = params?.src || 'search';
if (!paramsTerm || !paramsDifficulty) { if (!paramsTerm || !paramsDifficulty) {
return; return;
} }
@ -87,6 +88,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
instructions: paramsCustomInstructions, instructions: paramsCustomInstructions,
goal: paramsGoal, goal: paramsGoal,
about: paramsAbout, about: paramsAbout,
src: paramsSrc,
}); });
}, [term, difficulty]); }, [term, difficulty]);
@ -98,9 +100,18 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
about?: string; about?: string;
isForce?: boolean; isForce?: boolean;
prompt?: string; prompt?: string;
src?: string;
}) => { }) => {
const { term, difficulty, isForce, prompt, instructions, goal, about } = const {
options; term,
difficulty,
isForce,
prompt,
instructions,
goal,
about,
src,
} = options;
if (!isLoggedIn()) { if (!isLoggedIn()) {
window.location.href = '/ai'; window.location.href = '/ai';
@ -121,6 +132,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
about, about,
isForce, isForce,
prompt, 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" />} {Icon && <Icon className="inline-block h-3 w-3 fill-current" />}
{text} {text}
</span> </span>
<hr className="absolute inset-x-0 grow border-current" /> <span className="absolute inset-x-0 h-px w-full grow bg-current" />
</p> </p>
); );
} }

@ -14,7 +14,6 @@ import {
updateResourceProgress as updateResourceProgressApi, updateResourceProgress as updateResourceProgressApi,
} from '../../lib/resource-progress'; } from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page'; import { pageProgressMessage } from '../../stores/page';
import { TopicProgressButton } from './TopicProgressButton';
import { showLoginPopup } from '../../lib/popup'; import { showLoginPopup } from '../../lib/popup';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import type { import type {
@ -22,20 +21,33 @@ import type {
RoadmapContentDocument, RoadmapContentDocument,
} from '../CustomRoadmap/CustomRoadmap'; } from '../CustomRoadmap/CustomRoadmap';
import { markdownToHtml, sanitizeMarkdown } from '../../lib/markdown'; 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 { getUrlParams, parseUrl } from '../../lib/browser';
import { Spinner } from '../ReactIcons/Spinner'; import { Spinner } from '../ReactIcons/Spinner';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx'; 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 { lockBodyScroll } from '../../lib/dom.ts';
import { TopicDetailLink } from './TopicDetailLink.tsx'; import { TopicDetailLink } from './TopicDetailLink.tsx';
import { ResourceListSeparator } from './ResourceListSeparator.tsx'; import { ResourceListSeparator } from './ResourceListSeparator.tsx';
import { PaidResourceDisclaimer } from './PaidResourceDisclaimer.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 = { type TopicDetailProps = {
resourceId?: string; resourceId?: string;
resourceTitle?: string; resourceTitle?: string;
resourceType?: ResourceType; resourceType?: ResourceType;
renderer?: AllowedRoadmapRenderer;
isEmbed?: boolean; isEmbed?: boolean;
canSubmitContribution: boolean; canSubmitContribution: boolean;
@ -51,6 +63,14 @@ type PaidResourceType = {
const paidResourcesCache: Record<string, 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) { async function fetchRoadmapPaidResources(roadmapId: string) {
if (paidResourcesCache[roadmapId]) { if (paidResourcesCache[roadmapId]) {
return paidResourcesCache[roadmapId]; return paidResourcesCache[roadmapId];
@ -77,6 +97,7 @@ export function TopicDetail(props: TopicDetailProps) {
canSubmitContribution, canSubmitContribution,
resourceId: defaultResourceId, resourceId: defaultResourceId,
isEmbed = false, isEmbed = false,
renderer = 'balsamiq',
resourceTitle, resourceTitle,
} = props; } = props;
@ -91,6 +112,13 @@ export function TopicDetail(props: TopicDetailProps) {
const [topicTitle, setTopicTitle] = useState(''); const [topicTitle, setTopicTitle] = useState('');
const [topicHtmlTitle, setTopicHtmlTitle] = useState(''); const [topicHtmlTitle, setTopicHtmlTitle] = useState('');
const [links, setLinks] = useState<RoadmapContentDocument['links']>([]); 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 toast = useToast();
const [showPaidResourceDisclaimer, setShowPaidResourceDisclaimer] = const [showPaidResourceDisclaimer, setShowPaidResourceDisclaimer] =
@ -106,14 +134,16 @@ export function TopicDetail(props: TopicDetailProps) {
const [resourceType, setResourceType] = useState<ResourceType>('roadmap'); const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
const [paidResources, setPaidResources] = useState<PaidResourceType[]>([]); const [paidResources, setPaidResources] = useState<PaidResourceType[]>([]);
// Close the topic detail when user clicks outside the topic detail const handleClose = () => {
useOutsideClick(topicRef, () => {
setIsActive(false); setIsActive(false);
}); setShowUpgradeModal(false);
setAiChatHistory(defaultChatHistory);
setActiveTab('content');
};
useKeydown('Escape', () => { // Close the topic detail when user clicks outside the topic detail
setIsActive(false); useOutsideClick(topicRef, handleClose);
}); useKeydown('Escape', handleClose);
useEffect(() => { useEffect(() => {
if (resourceType !== 'roadmap' || !defaultResourceId) { if (resourceType !== 'roadmap' || !defaultResourceId) {
@ -177,6 +207,7 @@ export function TopicDetail(props: TopicDetailProps) {
setTopicId(topicId); setTopicId(topicId);
setResourceType(resourceType); setResourceType(resourceType);
setResourceId(resourceId); setResourceId(resourceId);
setIsCustomResource(isCustomResource);
const topicPartial = topicId.replaceAll(':', '/'); const topicPartial = topicId.replaceAll(':', '/');
let topicUrl = let topicUrl =
@ -335,15 +366,21 @@ export function TopicDetail(props: TopicDetailProps) {
(resource) => resource?.url?.toLowerCase().indexOf('scrimba') !== -1, (resource) => resource?.url?.toLowerCase().indexOf('scrimba') !== -1,
); );
const shouldShowAiTab = !isCustomResource && resourceType === 'roadmap';
return ( return (
<div className={'relative z-92'}> <div className={'relative z-92'}>
<div <div
ref={topicRef} ref={topicRef}
tabIndex={0} 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 && ( {isLoading && (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full justify-center">
<Spinner <Spinner
outerFill="#d1d5db" outerFill="#d1d5db"
className="h-6 w-6 sm:h-8 sm:w-8" className="h-6 w-6 sm:h-8 sm:w-8"
@ -355,234 +392,229 @@ export function TopicDetail(props: TopicDetailProps) {
{!isContributing && !isLoading && !error && ( {!isContributing && !isLoading && !error && (
<> <>
<div className="flex-1"> <div
{/* Actions for the topic */} className={cn('flex-1', {
<div className="mb-2"> 'flex flex-col': activeTab === 'ai',
{!isEmbed && ( })}
<TopicProgressButton >
topicId={ <div className="flex justify-between">
topicId.indexOf('@') !== -1 {shouldShowAiTab && (
? topicId.split('@')[1] <TopicDetailsTabs
: topicId activeTab={activeTab}
} setActiveTab={setActiveTab}
resourceId={resourceId} hasAITutor={renderer === 'editor'}
resourceType={resourceType}
onClose={() => {
setIsActive(false);
}}
/> />
)} )}
<div
<button className={cn('flex flex-grow justify-end gap-1', {
type="button" 'justify-between': !shouldShowAiTab,
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);
}}
> >
<X className="h-5 w-5" /> {!isEmbed && (
</button> <TopicProgressButton
</div> topicId={
topicId.indexOf('@') !== -1
{/* Topic Content */} ? topicId.split('@')[1]
{hasContent ? ( : topicId
<> }
<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"> dropdownClassName={
{topicTitle && <h1>{topicTitle}</h1>} !shouldShowAiTab ? 'left-0' : 'right-0'
<div }
id="topic-content" resourceId={resourceId}
dangerouslySetInnerHTML={{ __html: topicHtml }} 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 && ( <button
<div className="mx-auto flex h-[calc(100%-38px)] max-w-[400px] flex-col items-center justify-center text-center"> type="button"
<HeartHandshake className="mb-2 h-16 w-16 text-gray-300" /> id="close-topic"
<p className="text-lg font-semibold text-gray-900"> 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"
Help us write this content onClick={handleClose}
</p> >
<p className="mb-3 mt-2 text-sm text-gray-500"> <X className="size-4" />
Write a brief introduction to this topic and submit a </button>
link to a good article, podcast, video, or any other </div>
self-vetted resource that helped you understand this </div>
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>
)}
</>
)}
{links.length > 0 && ( {activeTab === 'ai' && shouldShowAiTab && (
<> <TopicDetailAI
<ResourceListSeparator resourceId={resourceId}
text="Free Resources" resourceType={resourceType}
className="text-green-600" topicId={topicId}
icon={HeartHandshake} aiChatHistory={aiChatHistory}
/> setAiChatHistory={setAiChatHistory}
<ul className="ml-3 mt-4 space-y-1"> onUpgrade={() => setShowUpgradeModal(true)}
{links.map((link) => { onLogin={() => {
return ( handleClose();
<li key={link.id}> showLoginPopup();
<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 && ( {activeTab === 'content' && (
<> <>
<ResourceListSeparator text="Premium Resources" icon={Star} /> {hasContent ? (
<>
<ul className="ml-3 mt-3 space-y-1"> <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">
{paidResourcesForTopic.map((resource) => { {topicTitle && <h1>{topicTitle}</h1>}
return ( <div
<li key={resource._id}> id="topic-content"
<TopicDetailLink dangerouslySetInnerHTML={{ __html: topicHtml }}
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>
</div> </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 && ( {links.length > 0 && (
<PaidResourceDisclaimer <>
onClose={() => { <ResourceListSeparator
localStorage.setItem( text="Free Resources"
PAID_RESOURCE_DISCLAIMER_HIDDEN, className="text-green-600"
'true', icon={HeartHandshake}
); />
setShowPaidResourceDisclaimer(false); <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> </div>
{resourceId === 'devops' && (
<div className="mt-4"> {canSubmitContribution &&
<a contributionUrl &&
href={tnsLink} activeTab === 'content' &&
target="_blank" hasContent && (
className="hidden rounded-md border bg-gray-200 px-2 py-2 text-sm hover:bg-gray-300 sm:block" <div className="mt-4">
> <a
<span className="badge mr-1.5">Partner</span> href={contributionUrl}
Get the latest {resourceTitleFromId(resourceId)} news from our target="_blank"
sister site{' '} className="hidden items-center justify-center rounded-md border bg-gray-200 px-2 py-2 text-sm hover:bg-gray-300 sm:flex"
<span className="font-medium underline underline-offset-1"> >
TheNewStack.io <GitHubIcon className="mr-2 inline-block h-4 w-4 text-current" />
</span> Help us Improve this Content
</a> </a>
<a <a
href={tnsLink} 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" 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={() => { onClick={() => {
window.fireEvent({ window.fireEvent({
category: 'PartnerClick', category: 'PartnerClick',
action: 'TNS Redirect', action: 'TNS Redirect',
label: 'Roadmap Topic / TNS Link', label: 'Roadmap Topic / TNS Link',
}); });
}} }}
> >
<span className="badge mr-1.5">Partner</span> <span className="badge mr-1.5">Partner</span>
Visit{' '} Visit{' '}
<span className="font-medium underline underline-offset-1"> <span className="font-medium underline underline-offset-1">
TheNewStack.io TheNewStack.io
</span>{' '} </span>{' '}
for {resourceTitleFromId(resourceId)} news for {resourceTitleFromId(resourceId)} news
</a> </a>
</div> </div>
)} )}
</> </>
)} )}
{/* Error */}
{!isContributing && !isLoading && error && ( {!isContributing && !isLoading && error && (
<> <>
<button <button
type="button" type="button"
id="close-topic" 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={() => { onClick={() => {
setIsActive(false); setIsActive(false);
setIsContributing(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 { cn } from '../../lib/classname.ts';
import type { AllowedLinkTypes } from '../CustomRoadmap/CustomRoadmap.tsx'; import type { AllowedLinkTypes } from '../CustomRoadmap/CustomRoadmap.tsx';
const linkTypes: Record<AllowedLinkTypes, string> = { const linkTypes: Record<AllowedLinkTypes | string, string> = {
article: 'bg-yellow-300', article: 'bg-yellow-300',
course: 'bg-green-400', course: 'bg-green-400',
opensource: 'bg-black text-white', opensource: 'bg-black text-white',
@ -18,6 +18,34 @@ const paidLinkTypes: Record<string, string> = {
course: 'bg-yellow-300', 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 = { type TopicDetailLinkProps = {
url: string; url: string;
onClick?: () => void; onClick?: () => void;
@ -29,7 +57,7 @@ type TopicDetailLinkProps = {
export function TopicDetailLink(props: TopicDetailLinkProps) { export function TopicDetailLink(props: TopicDetailLinkProps) {
const { url, onClick, type, title, isPaid = false } = props; const { url, onClick, type, title, isPaid = false } = props;
const linkType = type === 'opensource' ? 'OpenSource' : type; const isScrimbaLink = url.toLowerCase().includes('scrimba.com');
return ( return (
<a <a
@ -38,14 +66,14 @@ export function TopicDetailLink(props: TopicDetailLinkProps) {
className="group font-medium text-gray-800 underline underline-offset-1 hover:text-black" className="group font-medium text-gray-800 underline underline-offset-1 hover:text-black"
onClick={onClick} onClick={onClick}
> >
<span <TopicLinkBadge
className={cn( isPaid={isPaid}
'mr-2 inline-block rounded-sm px-1.5 py-0.5 text-xs capitalize no-underline', type={type}
(isPaid ? paidLinkTypes[type] : linkTypes[type]) || 'bg-gray-200', discountText={isScrimbaLink ? '20% off' : undefined}
)} className={isScrimbaLink ? 'mr-1' : 'mr-2'}
> />
{linkType} {isScrimbaLink && <TopicLinkBadge isPaid={isPaid} type="20% off" />}
</span>
{title} {title}
</a> </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 { useToast } from '../../hooks/use-toast';
import { Spinner } from '../ReactIcons/Spinner'; import { Spinner } from '../ReactIcons/Spinner';
import { ChevronDown } from 'lucide-react'; 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 = { type TopicProgressButtonProps = {
topicId: string; topicId: string;
resourceId: string; resourceId: string;
resourceType: ResourceType; resourceType: ResourceType;
dropdownClassName?: string;
onClose: () => void; onClose: () => void;
}; };
const statusColors: Record<ResourceProgressType, string> = { type ProgressDropdownItemProps = {
done: 'bg-green-500', status: ResourceProgressType;
learning: 'bg-yellow-500', shortcutKey: string;
pending: 'bg-gray-300', label: string;
skipped: 'bg-black', onClick: () => void;
removed: '',
}; };
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) { export function TopicProgressButton(props: TopicProgressButtonProps) {
const { topicId, resourceId, resourceType, onClose } = props; const { topicId, resourceId, resourceType, onClose, dropdownClassName } =
props;
const toast = useToast(); const toast = useToast();
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true); const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
@ -66,7 +95,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as done // Mark as done
useKeydown( useKeydown(
'd', 'd',
() => { (e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'done') { if (progress === 'done') {
onClose(); onClose();
return; return;
@ -80,7 +117,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as learning // Mark as learning
useKeydown( useKeydown(
'l', 'l',
() => { (e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'learning') { if (progress === 'learning') {
return; return;
} }
@ -93,7 +138,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as learning // Mark as learning
useKeydown( useKeydown(
's', 's',
() => { (e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'skipped') { if (progress === 'skipped') {
onClose(); onClose();
return; return;
@ -107,9 +160,16 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
// Mark as pending // Mark as pending
useKeydown( useKeydown(
'r', 'r',
() => { (e: KeyboardEvent) => {
if (
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
if (progress === 'pending') { if (progress === 'pending') {
onClose();
return; return;
} }
@ -147,6 +207,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
console.error(err); console.error(err);
}) })
.finally(() => { .finally(() => {
setShowChangeStatus(false);
setIsUpdatingProgress(false); setIsUpdatingProgress(false);
}); });
}; };
@ -167,15 +228,20 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
if (isUpdatingProgress) { if (isUpdatingProgress) {
return ( 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"> <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" /> <Spinner isDualRing={false} className="h-4 w-4" />
<span className="ml-2">Updating Status..</span> <span className="ml-2">Please wait..</span>
</button> </button>
); );
} }
return ( return (
<div className="relative inline-flex rounded-md border border-gray-300"> <div className="relative inline-flex">
<span className="inline-flex cursor-default items-center p-1 px-2 text-sm text-black"> <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="flex h-2 w-2">
<span <span
className={`relative inline-flex h-2 w-2 rounded-full ${statusColors[progress]}`} 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"> <span className="ml-2 capitalize">
{progress === 'learning' ? 'In Progress' : progress} {progress === 'learning' ? 'In Progress' : progress}
</span> </span>
</span> <ChevronDown className="ml-2 h-4 w-4" />
<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" />
</button> </button>
{showChangeStatus && ( {showChangeStatus && (
<div <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!} ref={changeStatusRef!}
> >
{allowMarkingDone && ( {allowMarkingDone && (
<button <ProgressDropdownItem
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100" status="done"
shortcutKey="D"
label="Done"
onClick={() => handleUpdateResourceProgress('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 && ( {allowMarkingLearning && (
<button <ProgressDropdownItem
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100" status="learning"
shortcutKey="L"
label="In Progress"
onClick={() => handleUpdateResourceProgress('learning')} 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 && ( {allowMarkingPending && (
<button <ProgressDropdownItem
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100" status="pending"
shortcutKey="R"
label="Reset"
onClick={() => handleUpdateResourceProgress('pending')} 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 && ( {allowMarkingSkipped && (
<button <ProgressDropdownItem
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100" status="skipped"
shortcutKey="S"
label="Skip"
onClick={() => handleUpdateResourceProgress('skipped')} 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> </div>
)} )}

@ -1,10 +1,10 @@
# How Does The Internet Work # 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: 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?](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) - [@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) - [@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: 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) - [@article@The Internet](https://en.wikipedia.org/wiki/Internet)

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

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

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

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