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. 190
      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,9 +392,24 @@ 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',
})}
>
<div className="flex justify-between">
{shouldShowAiTab && (
<TopicDetailsTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
hasAITutor={renderer === 'editor'}
/>
)}
<div
className={cn('flex flex-grow justify-end gap-1', {
'justify-between': !shouldShowAiTab,
})}
>
{!isEmbed && ( {!isEmbed && (
<TopicProgressButton <TopicProgressButton
topicId={ topicId={
@ -365,27 +417,42 @@ export function TopicDetail(props: TopicDetailProps) {
? topicId.split('@')[1] ? topicId.split('@')[1]
: topicId : topicId
} }
dropdownClassName={
!shouldShowAiTab ? 'left-0' : 'right-0'
}
resourceId={resourceId} resourceId={resourceId}
resourceType={resourceType} resourceType={resourceType}
onClose={() => { onClose={handleClose}
setIsActive(false);
}}
/> />
)} )}
<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="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={() => { onClick={handleClose}
setIsActive(false);
}}
> >
<X className="h-5 w-5" /> <X className="size-4" />
</button> </button>
</div> </div>
</div>
{/* Topic Content */} {activeTab === 'ai' && shouldShowAiTab && (
<TopicDetailAI
resourceId={resourceId}
resourceType={resourceType}
topicId={topicId}
aiChatHistory={aiChatHistory}
setAiChatHistory={setAiChatHistory}
onUpgrade={() => setShowUpgradeModal(true)}
onLogin={() => {
handleClose();
showLoginPopup();
}}
/>
)}
{activeTab === 'content' && (
<>
{hasContent ? ( {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"> <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">
@ -412,11 +479,11 @@ export function TopicDetail(props: TopicDetailProps) {
<p className="text-lg font-semibold text-gray-900"> <p className="text-lg font-semibold text-gray-900">
Help us write this content Help us write this content
</p> </p>
<p className="mb-3 mt-2 text-sm text-gray-500"> <p className="mt-2 mb-3 text-sm text-gray-500">
Write a brief introduction to this topic and submit a Write a brief introduction to this topic and submit
link to a good article, podcast, video, or any other a link to a good article, podcast, video, or any
self-vetted resource that helped you understand this other self-vetted resource that helped you
topic better. understand this topic better.
</p> </p>
<a <a
href={contributionUrl} href={contributionUrl}
@ -438,7 +505,7 @@ export function TopicDetail(props: TopicDetailProps) {
className="text-green-600" className="text-green-600"
icon={HeartHandshake} icon={HeartHandshake}
/> />
<ul className="ml-3 mt-4 space-y-1"> <ul className="mt-4 ml-3 space-y-1">
{links.map((link) => { {links.map((link) => {
return ( return (
<li key={link.id}> <li key={link.id}>
@ -468,9 +535,12 @@ export function TopicDetail(props: TopicDetailProps) {
{paidResourcesForTopic.length > 0 && ( {paidResourcesForTopic.length > 0 && (
<> <>
<ResourceListSeparator text="Premium Resources" icon={Star} /> <ResourceListSeparator
text="Premium Resources"
icon={Star}
/>
<ul className="ml-3 mt-3 space-y-1"> <ul className="mt-3 ml-3 space-y-1">
{paidResourcesForTopic.map((resource) => { {paidResourcesForTopic.map((resource) => {
return ( return (
<li key={resource._id}> <li key={resource._id}>
@ -485,19 +555,6 @@ export function TopicDetail(props: TopicDetailProps) {
})} })}
</ul> </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>
)}
{showPaidResourceDisclaimer && ( {showPaidResourceDisclaimer && (
<PaidResourceDisclaimer <PaidResourceDisclaimer
onClose={() => { onClose={() => {
@ -511,46 +568,22 @@ export function TopicDetail(props: TopicDetailProps) {
)} )}
</> </>
)} )}
</>
)}
</div>
{/* Contribution */}
{canSubmitContribution && {canSubmitContribution &&
!hasEnoughLinks &&
contributionUrl && contributionUrl &&
activeTab === 'content' &&
hasContent && ( 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"> <div className="mt-4">
<a <a
href={tnsLink} href={contributionUrl}
target="_blank" target="_blank"
className="hidden rounded-md border bg-gray-200 px-2 py-2 text-sm hover:bg-gray-300 sm:block" 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="badge mr-1.5">Partner</span> <GitHubIcon className="mr-2 inline-block h-4 w-4 text-current" />
Get the latest {resourceTitleFromId(resourceId)} news from our Help us Improve this Content
sister site{' '}
<span className="font-medium underline underline-offset-1">
TheNewStack.io
</span>
</a> </a>
<a <a
@ -576,13 +609,12 @@ export function TopicDetail(props: TopicDetailProps) {
</> </>
)} )}
{/* 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