mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-12 17:51:53 +08:00
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>
This commit is contained in:
1
.astro/types.d.ts
vendored
1
.astro/types.d.ts
vendored
@@ -1,2 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
||||
@@ -118,6 +118,7 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
|
||||
resourceId={roadmap!._id}
|
||||
resourceTitle={roadmap!.title}
|
||||
resourceType="roadmap"
|
||||
renderer='editor'
|
||||
isEmbed={isEmbed}
|
||||
canSubmitContribution={false}
|
||||
/>
|
||||
|
||||
@@ -399,7 +399,7 @@ type AIChatCardProps = {
|
||||
html?: string;
|
||||
};
|
||||
|
||||
function AIChatCard(props: AIChatCardProps) {
|
||||
export function AIChatCard(props: AIChatCardProps) {
|
||||
const { role, content, html: defaultHtml } = props;
|
||||
|
||||
const html = useMemo(() => {
|
||||
|
||||
@@ -54,6 +54,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
const params = getUrlParams();
|
||||
const paramsTerm = params?.term;
|
||||
const paramsDifficulty = params?.difficulty;
|
||||
const paramsSrc = params?.src || 'search';
|
||||
if (!paramsTerm || !paramsDifficulty) {
|
||||
return;
|
||||
}
|
||||
@@ -87,6 +88,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
instructions: paramsCustomInstructions,
|
||||
goal: paramsGoal,
|
||||
about: paramsAbout,
|
||||
src: paramsSrc,
|
||||
});
|
||||
}, [term, difficulty]);
|
||||
|
||||
@@ -98,9 +100,18 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
about?: string;
|
||||
isForce?: boolean;
|
||||
prompt?: string;
|
||||
src?: string;
|
||||
}) => {
|
||||
const { term, difficulty, isForce, prompt, instructions, goal, about } =
|
||||
options;
|
||||
const {
|
||||
term,
|
||||
difficulty,
|
||||
isForce,
|
||||
prompt,
|
||||
instructions,
|
||||
goal,
|
||||
about,
|
||||
src,
|
||||
} = options;
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
window.location.href = '/ai';
|
||||
@@ -121,6 +132,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
about,
|
||||
isForce,
|
||||
prompt,
|
||||
src,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
54
src/components/TopicDetail/PredefinedActionGroup.tsx
Normal file
54
src/components/TopicDetail/PredefinedActionGroup.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
144
src/components/TopicDetail/PredefinedActions.tsx
Normal file
144
src/components/TopicDetail/PredefinedActions.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
BabyIcon,
|
||||
BookOpenTextIcon,
|
||||
BrainIcon,
|
||||
ChevronDownIcon,
|
||||
ListIcon,
|
||||
NotebookPenIcon,
|
||||
PencilLine,
|
||||
Star,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { PredefinedActionGroup } from './PredefinedActionGroup';
|
||||
|
||||
export type PredefinedActionType = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
prompt?: string;
|
||||
children?: PredefinedActionType[];
|
||||
};
|
||||
|
||||
export const actions: PredefinedActionType[] = [
|
||||
{
|
||||
icon: BookOpenTextIcon,
|
||||
label: 'Explain',
|
||||
children: [
|
||||
{
|
||||
icon: NotebookPenIcon,
|
||||
label: 'Explain the topic',
|
||||
prompt: 'Explain this topic in detail and include examples',
|
||||
},
|
||||
{
|
||||
icon: ListIcon,
|
||||
label: 'List the key points',
|
||||
prompt: 'List the key points to remember from this topic',
|
||||
},
|
||||
{
|
||||
icon: PencilLine,
|
||||
label: 'Summarize the topic',
|
||||
prompt:
|
||||
'Briefly explain the topic in a few sentences. Treat it as a brief answer to an interview question. Your response should just be the answer to the question, nothing else.',
|
||||
},
|
||||
{
|
||||
icon: BabyIcon,
|
||||
label: 'Explain like I am five',
|
||||
prompt: 'Explain this topic like I am a 5 years old',
|
||||
},
|
||||
{
|
||||
icon: Star,
|
||||
label: 'Why is it important?',
|
||||
prompt:
|
||||
'Why is this topic important? What are the real world applications (only add if appropriate)?',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: BrainIcon,
|
||||
label: 'Test my Knowledge',
|
||||
prompt:
|
||||
"Act as an interviewer and test my understanding of this topic. Ask me a single question at a time and evaluate my answer. Question number should be bold. After evaluating my answer, immediately proceed to the next question without asking if I'm ready or want another question. Continue asking questions until I explicitly tell you to stop.",
|
||||
},
|
||||
];
|
||||
|
||||
export const promptLabelMapping = actions.reduce(
|
||||
(acc, action) => {
|
||||
if (action.prompt) {
|
||||
acc[action.prompt] = action.label;
|
||||
}
|
||||
|
||||
if (action.children) {
|
||||
action.children.forEach((child) => {
|
||||
if (child.prompt) {
|
||||
acc[child.prompt] = child.label;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
type PredefinedActionsProps = {
|
||||
onSelect: (action: PredefinedActionType) => void;
|
||||
};
|
||||
|
||||
export function PredefinedActions(props: PredefinedActionsProps) {
|
||||
const { onSelect } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-gray-200 px-3 py-1 text-sm">
|
||||
{actions.map((action) => {
|
||||
if (!action.children) {
|
||||
return (
|
||||
<PredefinedActionButton
|
||||
key={action.label}
|
||||
icon={action.icon}
|
||||
label={action.label}
|
||||
onClick={() => {
|
||||
onSelect(action);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PredefinedActionGroup
|
||||
key={action.label}
|
||||
label={action.label}
|
||||
icon={action.icon}
|
||||
actions={action.children}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PredefinedActionButtonProps = {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
onClick: () => void;
|
||||
isGroup?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PredefinedActionButton(props: PredefinedActionButtonProps) {
|
||||
const { label, icon: Icon, onClick, isGroup = false, className } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 rounded-md bg-gray-200 px-2 py-1 text-sm whitespace-nowrap hover:bg-gray-300',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{Icon && <Icon className="mr-1 size-3.5" />}
|
||||
{label}
|
||||
{isGroup && <ChevronDownIcon className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function ResourceListSeparator(props: ResourceSeparatorProps) {
|
||||
{Icon && <Icon className="inline-block h-3 w-3 fill-current" />}
|
||||
{text}
|
||||
</span>
|
||||
<hr className="absolute inset-x-0 grow border-current" />
|
||||
<span className="absolute inset-x-0 h-px w-full grow bg-current" />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
updateResourceProgress as updateResourceProgressApi,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { TopicProgressButton } from './TopicProgressButton';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type {
|
||||
@@ -22,20 +21,33 @@ import type {
|
||||
RoadmapContentDocument,
|
||||
} from '../CustomRoadmap/CustomRoadmap';
|
||||
import { markdownToHtml, sanitizeMarkdown } from '../../lib/markdown';
|
||||
import { Ban, Coins, FileText, HeartHandshake, Star, X } from 'lucide-react';
|
||||
import { Ban, FileText, HeartHandshake, Star, X } from 'lucide-react';
|
||||
import { getUrlParams, parseUrl } from '../../lib/browser';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
|
||||
import { resourceTitleFromId } from '../../lib/roadmap.ts';
|
||||
import {
|
||||
resourceTitleFromId,
|
||||
type AllowedRoadmapRenderer,
|
||||
} from '../../lib/roadmap.ts';
|
||||
import { lockBodyScroll } from '../../lib/dom.ts';
|
||||
import { TopicDetailLink } from './TopicDetailLink.tsx';
|
||||
import { ResourceListSeparator } from './ResourceListSeparator.tsx';
|
||||
import { PaidResourceDisclaimer } from './PaidResourceDisclaimer.tsx';
|
||||
import {
|
||||
TopicDetailsTabs,
|
||||
type AllowedTopicDetailsTabs,
|
||||
} from './TopicDetailsTabs.tsx';
|
||||
import { TopicDetailAI } from './TopicDetailAI.tsx';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import type { AIChatHistoryType } from '../GenerateCourse/AICourseLessonChat.tsx';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal.tsx';
|
||||
import { TopicProgressButton } from './TopicProgressButton.tsx';
|
||||
|
||||
type TopicDetailProps = {
|
||||
resourceId?: string;
|
||||
resourceTitle?: string;
|
||||
resourceType?: ResourceType;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
|
||||
isEmbed?: boolean;
|
||||
canSubmitContribution: boolean;
|
||||
@@ -51,6 +63,14 @@ type PaidResourceType = {
|
||||
|
||||
const paidResourcesCache: Record<string, PaidResourceType[]> = {};
|
||||
|
||||
export const defaultChatHistory: AIChatHistoryType[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hey, I am your AI instructor. How can I help you today? 🤖',
|
||||
isDefault: true,
|
||||
},
|
||||
];
|
||||
|
||||
async function fetchRoadmapPaidResources(roadmapId: string) {
|
||||
if (paidResourcesCache[roadmapId]) {
|
||||
return paidResourcesCache[roadmapId];
|
||||
@@ -77,6 +97,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
canSubmitContribution,
|
||||
resourceId: defaultResourceId,
|
||||
isEmbed = false,
|
||||
renderer = 'balsamiq',
|
||||
resourceTitle,
|
||||
} = props;
|
||||
|
||||
@@ -91,6 +112,13 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
const [topicTitle, setTopicTitle] = useState('');
|
||||
const [topicHtmlTitle, setTopicHtmlTitle] = useState('');
|
||||
const [links, setLinks] = useState<RoadmapContentDocument['links']>([]);
|
||||
const [activeTab, setActiveTab] =
|
||||
useState<AllowedTopicDetailsTabs>('content');
|
||||
const [aiChatHistory, setAiChatHistory] =
|
||||
useState<AIChatHistoryType[]>(defaultChatHistory);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [isCustomResource, setIsCustomResource] = useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [showPaidResourceDisclaimer, setShowPaidResourceDisclaimer] =
|
||||
@@ -106,14 +134,16 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
|
||||
const [paidResources, setPaidResources] = useState<PaidResourceType[]>([]);
|
||||
|
||||
// Close the topic detail when user clicks outside the topic detail
|
||||
useOutsideClick(topicRef, () => {
|
||||
const handleClose = () => {
|
||||
setIsActive(false);
|
||||
});
|
||||
setShowUpgradeModal(false);
|
||||
setAiChatHistory(defaultChatHistory);
|
||||
setActiveTab('content');
|
||||
};
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
setIsActive(false);
|
||||
});
|
||||
// Close the topic detail when user clicks outside the topic detail
|
||||
useOutsideClick(topicRef, handleClose);
|
||||
useKeydown('Escape', handleClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (resourceType !== 'roadmap' || !defaultResourceId) {
|
||||
@@ -177,6 +207,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
setTopicId(topicId);
|
||||
setResourceType(resourceType);
|
||||
setResourceId(resourceId);
|
||||
setIsCustomResource(isCustomResource);
|
||||
|
||||
const topicPartial = topicId.replaceAll(':', '/');
|
||||
let topicUrl =
|
||||
@@ -335,15 +366,21 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
(resource) => resource?.url?.toLowerCase().indexOf('scrimba') !== -1,
|
||||
);
|
||||
|
||||
const shouldShowAiTab = !isCustomResource && resourceType === 'roadmap';
|
||||
|
||||
return (
|
||||
<div className={'relative z-92'}>
|
||||
<div
|
||||
ref={topicRef}
|
||||
tabIndex={0}
|
||||
className="fixed right-0 top-0 z-40 flex h-screen w-full flex-col overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
|
||||
className="fixed top-0 right-0 z-40 flex h-screen w-full flex-col overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
|
||||
>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex h-full w-full justify-center">
|
||||
<Spinner
|
||||
outerFill="#d1d5db"
|
||||
className="h-6 w-6 sm:h-8 sm:w-8"
|
||||
@@ -355,234 +392,229 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
|
||||
{!isContributing && !isLoading && !error && (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
{/* Actions for the topic */}
|
||||
<div className="mb-2">
|
||||
{!isEmbed && (
|
||||
<TopicProgressButton
|
||||
topicId={
|
||||
topicId.indexOf('@') !== -1
|
||||
? topicId.split('@')[1]
|
||||
: topicId
|
||||
}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
onClose={() => {
|
||||
setIsActive(false);
|
||||
}}
|
||||
<div
|
||||
className={cn('flex-1', {
|
||||
'flex flex-col': activeTab === 'ai',
|
||||
})}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
{shouldShowAiTab && (
|
||||
<TopicDetailsTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
hasAITutor={renderer === 'editor'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="close-topic"
|
||||
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
|
||||
onClick={() => {
|
||||
setIsActive(false);
|
||||
}}
|
||||
<div
|
||||
className={cn('flex flex-grow justify-end gap-1', {
|
||||
'justify-between': !shouldShowAiTab,
|
||||
})}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
{!isEmbed && (
|
||||
<TopicProgressButton
|
||||
topicId={
|
||||
topicId.indexOf('@') !== -1
|
||||
? topicId.split('@')[1]
|
||||
: topicId
|
||||
}
|
||||
dropdownClassName={
|
||||
!shouldShowAiTab ? 'left-0' : 'right-0'
|
||||
}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
id="close-topic"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-gray-200 px-1.5 py-1 text-xs text-black hover:bg-gray-300 hover:text-gray-900"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topic Content */}
|
||||
{hasContent ? (
|
||||
<>
|
||||
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h1:text-balance prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
|
||||
{topicTitle && <h1>{topicTitle}</h1>}
|
||||
<div
|
||||
id="topic-content"
|
||||
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!canSubmitContribution && (
|
||||
<div className="flex h-[calc(100%-38px)] flex-col items-center justify-center">
|
||||
<FileText className="h-16 w-16 text-gray-300" />
|
||||
<p className="mt-2 text-lg font-medium text-gray-500">
|
||||
Empty Content
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{canSubmitContribution && (
|
||||
<div className="mx-auto flex h-[calc(100%-38px)] max-w-[400px] flex-col items-center justify-center text-center">
|
||||
<HeartHandshake className="mb-2 h-16 w-16 text-gray-300" />
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
Help us write this content
|
||||
</p>
|
||||
<p className="mb-3 mt-2 text-sm text-gray-500">
|
||||
Write a brief introduction to this topic and submit a
|
||||
link to a good article, podcast, video, or any other
|
||||
self-vetted resource that helped you understand this
|
||||
topic better.
|
||||
</p>
|
||||
<a
|
||||
href={contributionUrl}
|
||||
target={'_blank'}
|
||||
className="flex w-full items-center justify-center rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
|
||||
>
|
||||
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-white" />
|
||||
Help us Write this Content
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{activeTab === 'ai' && shouldShowAiTab && (
|
||||
<TopicDetailAI
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
topicId={topicId}
|
||||
aiChatHistory={aiChatHistory}
|
||||
setAiChatHistory={setAiChatHistory}
|
||||
onUpgrade={() => setShowUpgradeModal(true)}
|
||||
onLogin={() => {
|
||||
handleClose();
|
||||
showLoginPopup();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
{activeTab === 'content' && (
|
||||
<>
|
||||
<ResourceListSeparator
|
||||
text="Free Resources"
|
||||
className="text-green-600"
|
||||
icon={HeartHandshake}
|
||||
/>
|
||||
<ul className="ml-3 mt-4 space-y-1">
|
||||
{links.map((link) => {
|
||||
return (
|
||||
<li key={link.id}>
|
||||
<TopicDetailLink
|
||||
url={link.url}
|
||||
type={link.type}
|
||||
title={link.title}
|
||||
onClick={() => {
|
||||
// if it is one of our roadmaps, we want to track the click
|
||||
if (canSubmitContribution) {
|
||||
const parsedUrl = parseUrl(link.url);
|
||||
|
||||
window.fireEvent({
|
||||
category: 'TopicResourceClick',
|
||||
action: `Click: ${parsedUrl.hostname}`,
|
||||
label: `${resourceType} / ${resourceId} / ${topicId} / ${link.url}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{paidResourcesForTopic.length > 0 && (
|
||||
<>
|
||||
<ResourceListSeparator text="Premium Resources" icon={Star} />
|
||||
|
||||
<ul className="ml-3 mt-3 space-y-1">
|
||||
{paidResourcesForTopic.map((resource) => {
|
||||
return (
|
||||
<li key={resource._id}>
|
||||
<TopicDetailLink
|
||||
url={resource.url}
|
||||
type={resource.type as any}
|
||||
title={resource.title}
|
||||
isPaid={true}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{hasPaidScrimbaLinks && (
|
||||
<div className="relative -mb-1 ml-3 mt-4 rounded-md border border-yellow-300 bg-yellow-100 px-2.5 py-2 text-sm text-yellow-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="h-4 w-4 text-yellow-700" />
|
||||
<span>
|
||||
Scrimba is offering{' '}
|
||||
<span className={'font-semibold'}>20% off</span> on
|
||||
all courses for roadmap.sh users.
|
||||
</span>
|
||||
{hasContent ? (
|
||||
<>
|
||||
<div className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h1:text-balance prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5">
|
||||
{topicTitle && <h1>{topicTitle}</h1>}
|
||||
<div
|
||||
id="topic-content"
|
||||
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!canSubmitContribution && (
|
||||
<div className="flex h-[calc(100%-38px)] flex-col items-center justify-center">
|
||||
<FileText className="h-16 w-16 text-gray-300" />
|
||||
<p className="mt-2 text-lg font-medium text-gray-500">
|
||||
Empty Content
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{canSubmitContribution && (
|
||||
<div className="mx-auto flex h-[calc(100%-38px)] max-w-[400px] flex-col items-center justify-center text-center">
|
||||
<HeartHandshake className="mb-2 h-16 w-16 text-gray-300" />
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
Help us write this content
|
||||
</p>
|
||||
<p className="mt-2 mb-3 text-sm text-gray-500">
|
||||
Write a brief introduction to this topic and submit
|
||||
a link to a good article, podcast, video, or any
|
||||
other self-vetted resource that helped you
|
||||
understand this topic better.
|
||||
</p>
|
||||
<a
|
||||
href={contributionUrl}
|
||||
target={'_blank'}
|
||||
className="flex w-full items-center justify-center rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
|
||||
>
|
||||
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-white" />
|
||||
Help us Write this Content
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showPaidResourceDisclaimer && (
|
||||
<PaidResourceDisclaimer
|
||||
onClose={() => {
|
||||
localStorage.setItem(
|
||||
PAID_RESOURCE_DISCLAIMER_HIDDEN,
|
||||
'true',
|
||||
);
|
||||
setShowPaidResourceDisclaimer(false);
|
||||
}}
|
||||
/>
|
||||
{links.length > 0 && (
|
||||
<>
|
||||
<ResourceListSeparator
|
||||
text="Free Resources"
|
||||
className="text-green-600"
|
||||
icon={HeartHandshake}
|
||||
/>
|
||||
<ul className="mt-4 ml-3 space-y-1">
|
||||
{links.map((link) => {
|
||||
return (
|
||||
<li key={link.id}>
|
||||
<TopicDetailLink
|
||||
url={link.url}
|
||||
type={link.type}
|
||||
title={link.title}
|
||||
onClick={() => {
|
||||
// if it is one of our roadmaps, we want to track the click
|
||||
if (canSubmitContribution) {
|
||||
const parsedUrl = parseUrl(link.url);
|
||||
|
||||
window.fireEvent({
|
||||
category: 'TopicResourceClick',
|
||||
action: `Click: ${parsedUrl.hostname}`,
|
||||
label: `${resourceType} / ${resourceId} / ${topicId} / ${link.url}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{paidResourcesForTopic.length > 0 && (
|
||||
<>
|
||||
<ResourceListSeparator
|
||||
text="Premium Resources"
|
||||
icon={Star}
|
||||
/>
|
||||
|
||||
<ul className="mt-3 ml-3 space-y-1">
|
||||
{paidResourcesForTopic.map((resource) => {
|
||||
return (
|
||||
<li key={resource._id}>
|
||||
<TopicDetailLink
|
||||
url={resource.url}
|
||||
type={resource.type as any}
|
||||
title={resource.title}
|
||||
isPaid={true}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{showPaidResourceDisclaimer && (
|
||||
<PaidResourceDisclaimer
|
||||
onClose={() => {
|
||||
localStorage.setItem(
|
||||
PAID_RESOURCE_DISCLAIMER_HIDDEN,
|
||||
'true',
|
||||
);
|
||||
setShowPaidResourceDisclaimer(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contribution */}
|
||||
{canSubmitContribution &&
|
||||
!hasEnoughLinks &&
|
||||
contributionUrl &&
|
||||
hasContent && (
|
||||
<div className="mb-12 mt-3 border-t text-sm text-gray-400 sm:mt-12">
|
||||
<h2 className="mb-1 mt-4 text-base text-black font-medium">
|
||||
Help us add learning resources
|
||||
</h2>
|
||||
<p className="mb-4 leading-relaxed">
|
||||
This popup should be a brief introductory paragraph for
|
||||
the topic and a few links to good articles, videos, or any
|
||||
other self-vetted learning resources. Please consider submitting a
|
||||
PR to improve this content.
|
||||
</p>
|
||||
<a
|
||||
href={contributionUrl}
|
||||
target={'_blank'}
|
||||
className="flex w-full items-center justify-center rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
|
||||
>
|
||||
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-white" />
|
||||
Help us Improve this Content
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{resourceId === 'devops' && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={tnsLink}
|
||||
target="_blank"
|
||||
className="hidden rounded-md border bg-gray-200 px-2 py-2 text-sm hover:bg-gray-300 sm:block"
|
||||
>
|
||||
<span className="badge mr-1.5">Partner</span>
|
||||
Get the latest {resourceTitleFromId(resourceId)} news from our
|
||||
sister site{' '}
|
||||
<span className="font-medium underline underline-offset-1">
|
||||
TheNewStack.io
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={tnsLink}
|
||||
className="hidden rounded-md border bg-gray-200 px-2 py-1.5 text-sm hover:bg-gray-300 min-[390px]:block sm:hidden"
|
||||
onClick={() => {
|
||||
window.fireEvent({
|
||||
category: 'PartnerClick',
|
||||
action: 'TNS Redirect',
|
||||
label: 'Roadmap Topic / TNS Link',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="badge mr-1.5">Partner</span>
|
||||
Visit{' '}
|
||||
<span className="font-medium underline underline-offset-1">
|
||||
TheNewStack.io
|
||||
</span>{' '}
|
||||
for {resourceTitleFromId(resourceId)} news
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{canSubmitContribution &&
|
||||
contributionUrl &&
|
||||
activeTab === 'content' &&
|
||||
hasContent && (
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href={contributionUrl}
|
||||
target="_blank"
|
||||
className="hidden items-center justify-center rounded-md border bg-gray-200 px-2 py-2 text-sm hover:bg-gray-300 sm:flex"
|
||||
>
|
||||
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-current" />
|
||||
Help us Improve this Content
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={tnsLink}
|
||||
className="hidden rounded-md border bg-gray-200 px-2 py-1.5 text-sm hover:bg-gray-300 min-[390px]:block sm:hidden"
|
||||
onClick={() => {
|
||||
window.fireEvent({
|
||||
category: 'PartnerClick',
|
||||
action: 'TNS Redirect',
|
||||
label: 'Roadmap Topic / TNS Link',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="badge mr-1.5">Partner</span>
|
||||
Visit{' '}
|
||||
<span className="font-medium underline underline-offset-1">
|
||||
TheNewStack.io
|
||||
</span>{' '}
|
||||
for {resourceTitleFromId(resourceId)} news
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{!isContributing && !isLoading && error && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
id="close-topic"
|
||||
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
|
||||
className="absolute top-2.5 right-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
|
||||
onClick={() => {
|
||||
setIsActive(false);
|
||||
setIsContributing(false);
|
||||
|
||||
475
src/components/TopicDetail/TopicDetailAI.tsx
Normal file
475
src/components/TopicDetail/TopicDetailAI.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
import '../GenerateCourse/AICourseLessonChat.css';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useRef, Fragment, useCallback, useEffect } from 'react';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import {
|
||||
BotIcon,
|
||||
Gift,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
SendIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { cn } from '../../lib/classname';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { flushSync } from 'react-dom';
|
||||
import {
|
||||
AIChatCard,
|
||||
type AIChatHistoryType,
|
||||
} from '../GenerateCourse/AICourseLessonChat';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { readStream } from '../../lib/ai';
|
||||
import { markdownToHtmlWithHighlighting } from '../../lib/markdown';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import { defaultChatHistory } from './TopicDetail';
|
||||
import { AILimitsPopup } from '../GenerateCourse/AILimitsPopup';
|
||||
import { PredefinedActions, promptLabelMapping } from './PredefinedActions';
|
||||
|
||||
type TopicDetailAIProps = {
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
topicId: string;
|
||||
|
||||
aiChatHistory: AIChatHistoryType[];
|
||||
setAiChatHistory: (history: AIChatHistoryType[]) => void;
|
||||
|
||||
onUpgrade: () => void;
|
||||
onLogin: () => void;
|
||||
};
|
||||
|
||||
export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
const {
|
||||
aiChatHistory,
|
||||
setAiChatHistory,
|
||||
resourceId,
|
||||
resourceType,
|
||||
topicId,
|
||||
onUpgrade,
|
||||
onLogin,
|
||||
} = props;
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const scrollareaRef = useRef<HTMLDivElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const sanitizedTopicId = topicId?.includes('@')
|
||||
? topicId?.split('@')?.[1]
|
||||
: topicId;
|
||||
|
||||
const toast = useToast();
|
||||
const [message, setMessage] = useState('');
|
||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||
const [streamedMessage, setStreamedMessage] = useState('');
|
||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||
const { data: tokenUsage, isLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
const { data: roadmapTreeMapping, isLoading: isRoadmapTreeMappingLoading } =
|
||||
useQuery(
|
||||
{
|
||||
...roadmapTreeMappingOptions(resourceId),
|
||||
select: (data) => {
|
||||
const node = data.find(
|
||||
(mapping) => mapping.nodeId === sanitizedTopicId,
|
||||
);
|
||||
return node;
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||
const isPaidUser = userBillingDetails?.status === 'active';
|
||||
|
||||
const handleChatSubmit = (overrideMessage?: string) => {
|
||||
const trimmedMessage = (overrideMessage ?? message).trim();
|
||||
|
||||
if (
|
||||
!trimmedMessage ||
|
||||
isStreamingMessage ||
|
||||
!isLoggedIn() ||
|
||||
isLimitExceeded ||
|
||||
isLoading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newMessages: AIChatHistoryType[] = [
|
||||
...aiChatHistory,
|
||||
{
|
||||
role: 'user',
|
||||
content: trimmedMessage,
|
||||
},
|
||||
];
|
||||
|
||||
flushSync(() => {
|
||||
setAiChatHistory(newMessages);
|
||||
setMessage('');
|
||||
});
|
||||
|
||||
scrollToBottom();
|
||||
completeAITutorChat(newMessages);
|
||||
};
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
scrollareaRef.current?.scrollTo({
|
||||
top: scrollareaRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [scrollareaRef]);
|
||||
|
||||
const completeAITutorChat = async (messages: AIChatHistoryType[]) => {
|
||||
try {
|
||||
setIsStreamingMessage(true);
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-topic-detail-chat`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
resourceId,
|
||||
resourceType,
|
||||
topicId: sanitizedTopicId,
|
||||
messages: messages.slice(-10),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
toast.error(data?.message || 'Something went wrong');
|
||||
setAiChatHistory([...messages].slice(0, messages.length - 1));
|
||||
setIsStreamingMessage(false);
|
||||
|
||||
if (data.status === 401) {
|
||||
removeAuthToken();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
setIsStreamingMessage(false);
|
||||
toast.error('Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
await readStream(reader, {
|
||||
onStream: async (content) => {
|
||||
flushSync(() => {
|
||||
setStreamedMessage(content);
|
||||
});
|
||||
|
||||
scrollToBottom();
|
||||
},
|
||||
onStreamEnd: async (content) => {
|
||||
const newMessages: AIChatHistoryType[] = [
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content,
|
||||
html: await markdownToHtmlWithHighlighting(content),
|
||||
},
|
||||
];
|
||||
|
||||
flushSync(() => {
|
||||
setStreamedMessage('');
|
||||
setIsStreamingMessage(false);
|
||||
setAiChatHistory(newMessages);
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
scrollToBottom();
|
||||
},
|
||||
});
|
||||
|
||||
setIsStreamingMessage(false);
|
||||
} catch (error) {
|
||||
toast.error('Something went wrong');
|
||||
setIsStreamingMessage(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, []);
|
||||
|
||||
const isDataLoading =
|
||||
isLoading || isBillingDetailsLoading || isRoadmapTreeMappingLoading;
|
||||
const usagePercentage = getPercentage(
|
||||
tokenUsage?.used || 0,
|
||||
tokenUsage?.limit || 0,
|
||||
);
|
||||
const hasSubjects =
|
||||
roadmapTreeMapping?.subjects && roadmapTreeMapping?.subjects?.length > 0;
|
||||
const hasChatHistory = aiChatHistory.length > 1;
|
||||
|
||||
return (
|
||||
<div className="relative mt-4 flex grow flex-col overflow-hidden rounded-lg border border-gray-200">
|
||||
{isDataLoading && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center gap-2 bg-white text-black">
|
||||
<Loader2Icon className="size-8 animate-spin stroke-3 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAILimitsPopup && (
|
||||
<AILimitsPopup
|
||||
onClose={() => setShowAILimitsPopup(false)}
|
||||
onUpgrade={() => {
|
||||
setShowAILimitsPopup(false);
|
||||
onUpgrade();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasSubjects && (
|
||||
<div className="border-b border-gray-200 p-3">
|
||||
<h4 className="flex items-center gap-2 text-sm">
|
||||
Complete the following AI Tutor courses
|
||||
</h4>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap gap-1 text-sm">
|
||||
{roadmapTreeMapping?.subjects?.map((subject) => {
|
||||
return (
|
||||
<a
|
||||
key={subject}
|
||||
target="_blank"
|
||||
href={`/ai/search?term=${subject}&difficulty=beginner&src=topic`}
|
||||
className="flex items-center gap-1 gap-2 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black"
|
||||
>
|
||||
{subject}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-[46px] items-center justify-between gap-2 border-gray-200 px-3 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
{hasSubjects && (
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<BotIcon
|
||||
className="relative -top-[1px] size-4 shrink-0 text-black"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<span className="hidden sm:block">Chat with AI</span>
|
||||
<span className="block sm:hidden">AI Tutor</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!hasSubjects && (
|
||||
<h4 className="flex items-center gap-2 text-base font-medium">
|
||||
<BotIcon
|
||||
className="relative -top-[1px] size-5 shrink-0 text-black"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
AI Tutor
|
||||
</h4>
|
||||
)}
|
||||
|
||||
{!isDataLoading && (
|
||||
<div className="flex gap-1.5">
|
||||
{hasChatHistory && (
|
||||
<button
|
||||
className="rounded-md bg-white py-2 px-2 text-xs font-medium text-black hover:bg-gray-200"
|
||||
onClick={() => {
|
||||
setAiChatHistory(defaultChatHistory);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isPaidUser && (
|
||||
<>
|
||||
<button
|
||||
className="hidden rounded-md bg-gray-200 px-2 py-1 text-sm hover:bg-gray-300 sm:block"
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
onLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
setShowAILimitsPopup(true);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">{usagePercentage}%</span>{' '}
|
||||
credits used
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md bg-yellow-400 px-2 py-1 text-sm text-black hover:bg-yellow-500"
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
onLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
onUpgrade();
|
||||
}}
|
||||
>
|
||||
<Gift className="size-4" />
|
||||
Upgrade
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PredefinedActions
|
||||
onSelect={(action) => {
|
||||
if (!isLoggedIn()) {
|
||||
onLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLimitExceeded) {
|
||||
onUpgrade();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!action?.prompt) {
|
||||
toast.error('Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage(action.prompt);
|
||||
handleChatSubmit(action.prompt);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto"
|
||||
ref={scrollareaRef}
|
||||
>
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
{aiChatHistory.map((chat, index) => {
|
||||
let content = chat.content;
|
||||
|
||||
if (chat.role === 'user' && promptLabelMapping[chat.content]) {
|
||||
content = promptLabelMapping[chat.content];
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`chat-${index}`}>
|
||||
<AIChatCard
|
||||
role={chat.role}
|
||||
content={content}
|
||||
html={chat.html}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
<AIChatCard role="assistant" content="Thinking..." />
|
||||
)}
|
||||
|
||||
{streamedMessage && (
|
||||
<AIChatCard role="assistant" content={streamedMessage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
className="relative flex items-start border-t border-gray-200 text-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleChatSubmit();
|
||||
}}
|
||||
>
|
||||
{isLimitExceeded && isLoggedIn() && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
|
||||
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} />
|
||||
<p className="cursor-not-allowed">
|
||||
Limit reached for today
|
||||
{isPaidUser ? '. Please wait until tomorrow.' : ''}
|
||||
</p>
|
||||
{!isPaidUser && (
|
||||
<button
|
||||
onClick={onUpgrade}
|
||||
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||
>
|
||||
Upgrade for more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isLoggedIn() && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
|
||||
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} />
|
||||
<p className="cursor-not-allowed">Please login to continue</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
showLoginPopup();
|
||||
}}
|
||||
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||
>
|
||||
Login / Register
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDataLoading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextareaAutosize
|
||||
className={cn(
|
||||
'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-hidden',
|
||||
)}
|
||||
placeholder="Ask AI anything about the lesson..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
autoFocus={true}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleChatSubmit();
|
||||
}
|
||||
}}
|
||||
ref={textareaRef}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isStreamingMessage || isLimitExceeded}
|
||||
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SendIcon className="size-4 stroke-[2.5]" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import type { AllowedLinkTypes } from '../CustomRoadmap/CustomRoadmap.tsx';
|
||||
|
||||
const linkTypes: Record<AllowedLinkTypes, string> = {
|
||||
const linkTypes: Record<AllowedLinkTypes | string, string> = {
|
||||
article: 'bg-yellow-300',
|
||||
course: 'bg-green-400',
|
||||
opensource: 'bg-black text-white',
|
||||
@@ -18,6 +18,34 @@ const paidLinkTypes: Record<string, string> = {
|
||||
course: 'bg-yellow-300',
|
||||
};
|
||||
|
||||
type TopicLinkBadgeProps = {
|
||||
isPaid: boolean;
|
||||
discountText?: string;
|
||||
type: AllowedLinkTypes | string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function TopicLinkBadge(props: TopicLinkBadgeProps) {
|
||||
const { isPaid, type, className } = props;
|
||||
|
||||
const linkType = type === 'opensource' ? 'OpenSource' : type;
|
||||
const isDiscount = type.includes('% off');
|
||||
|
||||
return (
|
||||
<span className={cn('mr-2', className)}>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block rounded-sm px-1.5 py-0.5 text-xs capitalize no-underline',
|
||||
(isPaid ? paidLinkTypes[type] : linkTypes[type]) || 'bg-gray-200',
|
||||
isDiscount && 'bg-green-300',
|
||||
)}
|
||||
>
|
||||
{linkType}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type TopicDetailLinkProps = {
|
||||
url: string;
|
||||
onClick?: () => void;
|
||||
@@ -29,7 +57,7 @@ type TopicDetailLinkProps = {
|
||||
export function TopicDetailLink(props: TopicDetailLinkProps) {
|
||||
const { url, onClick, type, title, isPaid = false } = props;
|
||||
|
||||
const linkType = type === 'opensource' ? 'OpenSource' : type;
|
||||
const isScrimbaLink = url.toLowerCase().includes('scrimba.com');
|
||||
|
||||
return (
|
||||
<a
|
||||
@@ -38,14 +66,14 @@ export function TopicDetailLink(props: TopicDetailLinkProps) {
|
||||
className="group font-medium text-gray-800 underline underline-offset-1 hover:text-black"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mr-2 inline-block rounded-sm px-1.5 py-0.5 text-xs capitalize no-underline',
|
||||
(isPaid ? paidLinkTypes[type] : linkTypes[type]) || 'bg-gray-200',
|
||||
)}
|
||||
>
|
||||
{linkType}
|
||||
</span>
|
||||
<TopicLinkBadge
|
||||
isPaid={isPaid}
|
||||
type={type}
|
||||
discountText={isScrimbaLink ? '20% off' : undefined}
|
||||
className={isScrimbaLink ? 'mr-1' : 'mr-2'}
|
||||
/>
|
||||
{isScrimbaLink && <TopicLinkBadge isPaid={isPaid} type="20% off" />}
|
||||
|
||||
{title}
|
||||
</a>
|
||||
);
|
||||
|
||||
68
src/components/TopicDetail/TopicDetailsTabs.tsx
Normal file
68
src/components/TopicDetail/TopicDetailsTabs.tsx
Normal file
@@ -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,14 +16,7 @@ import { showLoginPopup } from '../../lib/popup';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
type TopicProgressButtonProps = {
|
||||
topicId: string;
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
|
||||
onClose: () => void;
|
||||
};
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
const statusColors: Record<ResourceProgressType, string> = {
|
||||
done: 'bg-green-500',
|
||||
@@ -33,8 +26,44 @@ const statusColors: Record<ResourceProgressType, string> = {
|
||||
removed: '',
|
||||
};
|
||||
|
||||
type TopicProgressButtonProps = {
|
||||
topicId: string;
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
dropdownClassName?: string;
|
||||
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type ProgressDropdownItemProps = {
|
||||
status: ResourceProgressType;
|
||||
shortcutKey: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function ProgressDropdownItem(props: ProgressDropdownItemProps) {
|
||||
const { status, shortcutKey, label, onClick } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
className={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors[status]}`}
|
||||
></span>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{shortcutKey}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
const { topicId, resourceId, resourceType, onClose } = props;
|
||||
const { topicId, resourceId, resourceType, onClose, dropdownClassName } =
|
||||
props;
|
||||
|
||||
const toast = useToast();
|
||||
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
|
||||
@@ -66,7 +95,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
// Mark as done
|
||||
useKeydown(
|
||||
'd',
|
||||
() => {
|
||||
(e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress === 'done') {
|
||||
onClose();
|
||||
return;
|
||||
@@ -80,7 +117,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
// Mark as learning
|
||||
useKeydown(
|
||||
'l',
|
||||
() => {
|
||||
(e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress === 'learning') {
|
||||
return;
|
||||
}
|
||||
@@ -93,7 +138,15 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
// Mark as learning
|
||||
useKeydown(
|
||||
's',
|
||||
() => {
|
||||
(e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress === 'skipped') {
|
||||
onClose();
|
||||
return;
|
||||
@@ -107,9 +160,16 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
// Mark as pending
|
||||
useKeydown(
|
||||
'r',
|
||||
() => {
|
||||
(e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress === 'pending') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,6 +207,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setShowChangeStatus(false);
|
||||
setIsUpdatingProgress(false);
|
||||
});
|
||||
};
|
||||
@@ -167,15 +228,20 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
if (isUpdatingProgress) {
|
||||
return (
|
||||
<button className="inline-flex cursor-default items-center rounded-md border border-gray-300 bg-white p-1 px-2 text-sm text-black">
|
||||
<Spinner className="h-4 w-4" />
|
||||
<span className="ml-2">Updating Status..</span>
|
||||
<Spinner isDualRing={false} className="h-4 w-4" />
|
||||
<span className="ml-2">Please wait..</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex rounded-md border border-gray-300">
|
||||
<span className="inline-flex cursor-default items-center p-1 px-2 text-sm text-black">
|
||||
<div className="relative inline-flex">
|
||||
<button
|
||||
className={cn(
|
||||
'flex cursor-default cursor-pointer items-center rounded-md border border-gray-300 p-1 px-2 text-sm text-black hover:border-gray-400',
|
||||
)}
|
||||
onClick={() => setShowChangeStatus(true)}
|
||||
>
|
||||
<span className="flex h-2 w-2">
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${statusColors[progress]}`}
|
||||
@@ -184,77 +250,48 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
<span className="ml-2 capitalize">
|
||||
{progress === 'learning' ? 'In Progress' : progress}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
className="inline-flex cursor-pointer items-center rounded-br-md rounded-tr-md border-l border-l-gray-300 bg-gray-100 p-1 px-2 text-sm text-black hover:bg-gray-200"
|
||||
onClick={() => setShowChangeStatus(true)}
|
||||
>
|
||||
<span className="mr-0.5">Update Status</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{showChangeStatus && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 flex min-w-[160px] flex-col divide-y rounded-md border border-gray-200 bg-white shadow-md [&>button:first-child]:rounded-t-md [&>button:last-child]:rounded-b-md"
|
||||
className={cn(
|
||||
'absolute top-full right-0 z-50 mt-1 flex min-w-[160px] flex-col divide-y rounded-md border border-gray-200 bg-white shadow-md [&>button:first-child]:rounded-t-md [&>button:last-child]:rounded-b-md',
|
||||
dropdownClassName,
|
||||
)}
|
||||
ref={changeStatusRef!}
|
||||
>
|
||||
{allowMarkingDone && (
|
||||
<button
|
||||
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
|
||||
<ProgressDropdownItem
|
||||
status="done"
|
||||
shortcutKey="D"
|
||||
label="Done"
|
||||
onClick={() => handleUpdateResourceProgress('done')}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
className={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['done']}`}
|
||||
></span>
|
||||
Done
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">D</span>
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
{allowMarkingLearning && (
|
||||
<button
|
||||
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
|
||||
<ProgressDropdownItem
|
||||
status="learning"
|
||||
shortcutKey="L"
|
||||
label="In Progress"
|
||||
onClick={() => handleUpdateResourceProgress('learning')}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
className={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['learning']}`}
|
||||
></span>
|
||||
In Progress
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-gray-500">L</span>
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
{allowMarkingPending && (
|
||||
<button
|
||||
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
|
||||
<ProgressDropdownItem
|
||||
status="pending"
|
||||
shortcutKey="R"
|
||||
label="Reset"
|
||||
onClick={() => handleUpdateResourceProgress('pending')}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
className={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['pending']}`}
|
||||
></span>
|
||||
Reset
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">R</span>
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
{allowMarkingSkipped && (
|
||||
<button
|
||||
className="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
|
||||
<ProgressDropdownItem
|
||||
status="skipped"
|
||||
shortcutKey="S"
|
||||
label="Skip"
|
||||
onClick={() => handleUpdateResourceProgress('skipped')}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
className={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['skipped']}`}
|
||||
></span>
|
||||
Skip
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">S</span>
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# How Does The Internet Work
|
||||
|
||||
The Internet works through a global network of interconnected computers and servers, communicating via standardized protocols. Data is broken into packets and routed through various network nodes using the Internet Protocol (IP). These packets travel across different physical infrastructures, including fiber optic cables, satellites, and wireless networks. The Transmission Control Protocol (TCP) ensures reliable delivery and reassembly of packets at their destination. Domain Name System (DNS) servers translate human-readable website names into IP addresses. When you access a website, your device sends a request to the appropriate server, which responds with the requested data. This process, facilitated by routers, switches, and other networking equipment, enables the seamless exchange of information across vast distances, forming the backbone of our digital communications.
|
||||
The internet is a global network that connects computers and devices so they can share information with each other. It’s how you browse websites, send emails, watch videos, and use apps. Think of it like a giant web that links everything together.
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@roadmap@Introduction to Internet](https://roadmap.sh/guides/what-is-internet)
|
||||
- [@article@Introduction to Internet](https://roadmap.sh/guides/what-is-internet)
|
||||
- [@article@How does the Internet Work?](https://cs.fyi/guide/how-does-internet-work)
|
||||
- [@article@How Does the Internet Work? MDN Docs](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/How_does_the_Internet_work)
|
||||
- [@video@How the Internet Works in 5 Minutes](https://www.youtube.com/watch?v=7_LPdttKXPc)
|
||||
|
||||
@@ -4,5 +4,5 @@ The Internet is a global network of interconnected computer networks that use th
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@roadmap@Introduction to Internet](https://roadmap.sh/guides/what-is-internet)
|
||||
- [@article@Introduction to Internet](https://roadmap.sh/guides/what-is-internet)
|
||||
- [@article@The Internet](https://en.wikipedia.org/wiki/Internet)
|
||||
|
||||
@@ -20,6 +20,7 @@ type GenerateCourseOptions = {
|
||||
onCourseChange?: (course: AiCourse, rawData: string) => void;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
onError?: (error: string) => void;
|
||||
src?: string;
|
||||
};
|
||||
|
||||
export async function generateCourse(options: GenerateCourseOptions) {
|
||||
@@ -37,6 +38,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
|
||||
instructions,
|
||||
goal,
|
||||
about,
|
||||
src = 'search',
|
||||
} = options;
|
||||
|
||||
onLoadingChange?.(true);
|
||||
@@ -85,6 +87,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
|
||||
instructions,
|
||||
goal,
|
||||
about,
|
||||
src,
|
||||
}),
|
||||
credentials: 'include',
|
||||
},
|
||||
|
||||
@@ -7,14 +7,14 @@ export function useKeydown(keyName: string, callback: any, deps: any[] = []) {
|
||||
!keyName.startsWith('mod_') &&
|
||||
event.key.toLowerCase() === keyName.toLowerCase()
|
||||
) {
|
||||
callback();
|
||||
callback(event);
|
||||
} else if (
|
||||
keyName.startsWith('mod_') &&
|
||||
event.metaKey &&
|
||||
event.key.toLowerCase() === keyName.replace('mod_', '').toLowerCase()
|
||||
) {
|
||||
event.preventDefault();
|
||||
callback();
|
||||
callback(event);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import RoadmapNote from '../../components/RoadmapNote.astro';
|
||||
import { RoadmapTitleQuestion } from '../../components/RoadmapTitleQuestion';
|
||||
import ResourceProgressStats from '../../components/ResourceProgressStats.astro';
|
||||
import { getProjectsByRoadmapId } from '../../lib/project';
|
||||
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
@@ -101,6 +102,7 @@ const courses = roadmapData.courses || [];
|
||||
resourceTitle={roadmapData.title}
|
||||
resourceId={roadmapId}
|
||||
resourceType='roadmap'
|
||||
renderer={roadmapData.renderer}
|
||||
client:idle
|
||||
canSubmitContribution={true}
|
||||
/>
|
||||
@@ -183,5 +185,6 @@ const courses = roadmapData.courses || [];
|
||||
<RelatedRoadmaps roadmap={roadmapData} />
|
||||
</div>
|
||||
|
||||
<CheckSubscriptionVerification client:load />
|
||||
<div slot='changelog-banner'></div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type BestPracticeFrontmatter,
|
||||
getAllBestPractices,
|
||||
} from '../../../lib/best-practice';
|
||||
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
@@ -107,6 +108,7 @@ const ogImageUrl = getOpenGraphImageUrl({
|
||||
resourceId={bestPracticeId}
|
||||
resourceTitle={bestPracticeData.title}
|
||||
resourceType='best-practice'
|
||||
renderer={'balsamiq'}
|
||||
client:idle
|
||||
canSubmitContribution={true}
|
||||
/>
|
||||
@@ -136,6 +138,6 @@ const ogImageUrl = getOpenGraphImageUrl({
|
||||
/>
|
||||
|
||||
{bestPracticeData.isUpcoming && <UpcomingForm />}
|
||||
|
||||
<CheckSubscriptionVerification client:load />
|
||||
<div slot='changelog-banner'></div>
|
||||
</BaseLayout>
|
||||
|
||||
26
src/queries/roadmap-tree.ts
Normal file
26
src/queries/roadmap-tree.ts
Normal file
@@ -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}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user