mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-09 00:30:40 +02:00
feat: implement ai tutor
This commit is contained in:
@@ -49,6 +49,7 @@ import {
|
||||
import { TopicDetailAI } from './TopicDetailAI.tsx';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import type { AIChatHistoryType } from '../GenerateCourse/AICourseLessonChat.tsx';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal.tsx';
|
||||
|
||||
type TopicDetailProps = {
|
||||
resourceId?: string;
|
||||
@@ -121,6 +122,8 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
useState<AllowedTopicDetailsTabs>('content');
|
||||
const [aiChatHistory, setAiChatHistory] =
|
||||
useState<AIChatHistoryType[]>(defaultChatHistory);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [isCustomResource, setIsCustomResource] = useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
@@ -139,6 +142,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
|
||||
const handleClose = () => {
|
||||
setIsActive(false);
|
||||
setShowUpgradeModal(false);
|
||||
setAiChatHistory(defaultChatHistory);
|
||||
setActiveTab('content');
|
||||
};
|
||||
@@ -209,6 +213,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
setTopicId(topicId);
|
||||
setResourceType(resourceType);
|
||||
setResourceId(resourceId);
|
||||
setIsCustomResource(isCustomResource);
|
||||
|
||||
const topicPartial = topicId.replaceAll(':', '/');
|
||||
let topicUrl =
|
||||
@@ -369,6 +374,8 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
(resource) => resource?.url?.toLowerCase().indexOf('scrimba') !== -1,
|
||||
);
|
||||
|
||||
const shouldShowAiTab = !isCustomResource && resourceType === 'roadmap';
|
||||
|
||||
return (
|
||||
<div className={'relative z-92'}>
|
||||
<div
|
||||
@@ -376,6 +383,10 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
tabIndex={0}
|
||||
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">
|
||||
<Spinner
|
||||
@@ -394,7 +405,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
'flex flex-col': activeTab === 'ai',
|
||||
})}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className={cn('mb-6', !shouldShowAiTab && 'mb-2')}>
|
||||
{!isEmbed && (
|
||||
<TopicProgressButton
|
||||
topicId={
|
||||
@@ -418,15 +429,21 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TopicDetailsTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
{shouldShowAiTab && (
|
||||
<TopicDetailsTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'ai' && (
|
||||
{activeTab === 'ai' && shouldShowAiTab && (
|
||||
<TopicDetailAI
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
topicId={topicId}
|
||||
aiChatHistory={aiChatHistory}
|
||||
setAiChatHistory={setAiChatHistory}
|
||||
onUpgrade={() => setShowUpgradeModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@@ -11,7 +11,7 @@ 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, LockIcon, SendIcon } from 'lucide-react';
|
||||
import { BotIcon, Loader2Icon, LockIcon, SendIcon } from 'lucide-react';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { cn } from '../../lib/classname';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
@@ -23,15 +23,31 @@ import {
|
||||
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';
|
||||
|
||||
type TopicDetailAIProps = {
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
topicId: string;
|
||||
|
||||
aiChatHistory: AIChatHistoryType[];
|
||||
setAiChatHistory: (history: AIChatHistoryType[]) => void;
|
||||
|
||||
onUpgrade: () => void;
|
||||
};
|
||||
|
||||
export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
const { aiChatHistory, setAiChatHistory } = props;
|
||||
const {
|
||||
aiChatHistory,
|
||||
setAiChatHistory,
|
||||
resourceId,
|
||||
resourceType,
|
||||
topicId,
|
||||
onUpgrade,
|
||||
} = props;
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const scrollareaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toast = useToast();
|
||||
@@ -78,7 +94,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
});
|
||||
|
||||
scrollToBottom();
|
||||
// completeCourseAIChat(newMessages);
|
||||
completeAITutorChat(newMessages);
|
||||
};
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
@@ -88,86 +104,103 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
});
|
||||
}, [scrollareaRef]);
|
||||
|
||||
const completeCourseAIChat = async (messages: AIChatHistoryType[]) => {
|
||||
setIsStreamingMessage(true);
|
||||
const completeAITutorChat = async (messages: AIChatHistoryType[]) => {
|
||||
try {
|
||||
setIsStreamingMessage(true);
|
||||
|
||||
// const response = await fetch(
|
||||
// `${import.meta.env.PUBLIC_API_URL}/v1-follow-up-ai-course/${courseSlug}`,
|
||||
// {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// credentials: 'include',
|
||||
// body: JSON.stringify({
|
||||
// moduleTitle,
|
||||
// lessonTitle,
|
||||
// messages: messages.slice(-10),
|
||||
// }),
|
||||
// },
|
||||
// );
|
||||
const sanitizedTopicId = topicId?.includes('@')
|
||||
? topicId?.split('@')?.[1]
|
||||
: topicId;
|
||||
|
||||
const response = new Response();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
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),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
flushSync(() => {
|
||||
setStreamedMessage('');
|
||||
setIsStreamingMessage(false);
|
||||
setAiChatHistory(newMessages);
|
||||
});
|
||||
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());
|
||||
scrollToBottom();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStreamingMessage(false);
|
||||
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;
|
||||
const usagePercentage = getPercentage(
|
||||
tokenUsage?.used || 0,
|
||||
tokenUsage?.limit || 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex grow flex-col rounded-lg border">
|
||||
<div className="mt-4 flex grow flex-col overflow-hidden rounded-lg border">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm">
|
||||
<h4 className="flex items-center gap-2 text-base font-medium">
|
||||
<BotIcon
|
||||
@@ -176,6 +209,12 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
/>
|
||||
AI Tutor
|
||||
</h4>
|
||||
|
||||
{!isDataLoading && !isPaidUser && (
|
||||
<p className="text-sm text-gray-500">
|
||||
<span className="font-medium">{usagePercentage}%</span> used
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -193,31 +232,6 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
content={chat.content}
|
||||
html={chat.html}
|
||||
/>
|
||||
|
||||
{/* {chat.isDefault && defaultQuestions?.length > 1 && (
|
||||
<div className="mt-0.5 mb-1">
|
||||
<p className="mb-2 text-xs font-normal text-gray-500">
|
||||
Some questions you might have about this lesson.
|
||||
</p>
|
||||
<div className="flex flex-col justify-end gap-1">
|
||||
{defaultQuestions.map((question, index) => (
|
||||
<button
|
||||
key={`default-question-${index}`}
|
||||
className="flex h-full self-start rounded-md bg-yellow-500/10 px-3 py-2 text-left text-sm text-black hover:bg-yellow-500/20"
|
||||
onClick={() => {
|
||||
flushSync(() => {
|
||||
setMessage(question);
|
||||
});
|
||||
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
@@ -247,9 +261,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
</p>
|
||||
{!isPaidUser && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// onUpgradeClick();
|
||||
}}
|
||||
onClick={onUpgrade}
|
||||
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||
>
|
||||
Upgrade for more
|
||||
@@ -271,6 +283,14 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
</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',
|
||||
@@ -281,15 +301,15 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
autoFocus={true}
|
||||
onKeyDown={(e) => {
|
||||
// if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>);
|
||||
// }
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>);
|
||||
}
|
||||
}}
|
||||
// ref={textareaRef}
|
||||
ref={textareaRef}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
// disabled={isDisabled || isStreamingMessage || isLimitExceeded}
|
||||
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]" />
|
||||
|
@@ -66,7 +66,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 +88,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 +109,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 +131,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;
|
||||
}
|
||||
|
||||
@@ -175,7 +206,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
|
||||
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">
|
||||
<span className="inline-flex cursor-default items-center p-1 px-2 text-sm text-black">
|
||||
<span className="flex h-2 w-2">
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${statusColors[progress]}`}
|
||||
@@ -187,7 +218,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
</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"
|
||||
className="inline-flex cursor-pointer items-center rounded-tr-md rounded-br-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>
|
||||
@@ -196,7 +227,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
|
||||
{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="absolute top-full right-0 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"
|
||||
ref={changeStatusRef!}
|
||||
>
|
||||
{allowMarkingDone && (
|
||||
|
110
src/data/roadmaps/frontend/tree.json
Normal file
110
src/data/roadmaps/frontend/tree.json
Normal file
@@ -0,0 +1,110 @@
|
||||
[
|
||||
{
|
||||
"id": "VlNNwIEDWqQXtqkHWJYzC",
|
||||
"text": "Front-end > Internet"
|
||||
},
|
||||
{
|
||||
"id": "yCnn-NfSxIybUQ2iTuUGq",
|
||||
"text": "Front-end > Internet > How does the internet work?"
|
||||
},
|
||||
{
|
||||
"id": "R12sArWVpbIs_PHxBqVaR",
|
||||
"text": "Front-end > Internet > What is HTTP?"
|
||||
},
|
||||
{
|
||||
"id": "ZhSuu2VArnzPDp6dPQQSC",
|
||||
"text": "Front-end > Internet > What is Domain Name?"
|
||||
},
|
||||
{
|
||||
"id": "aqMaEY8gkKMikiqleV5EP",
|
||||
"text": "Front-end > Internet > What is hosting?"
|
||||
},
|
||||
{
|
||||
"id": "hkxw9jPGYphmjhTjw8766",
|
||||
"text": "Front-end > Internet > DNS and how it works?"
|
||||
},
|
||||
{
|
||||
"id": "P82WFaTPgQEPNp5IIuZ1Y",
|
||||
"text": "Front-end > Internet > Browsers and how they work?"
|
||||
},
|
||||
{
|
||||
"id": "yWG2VUkaF5IJVVut6AiSy",
|
||||
"text": "Front-end > HTML"
|
||||
},
|
||||
{
|
||||
"id": "mH_qff8R7R6eLQ1tPHLgG",
|
||||
"text": "Front-end > HTML > SEO Basics"
|
||||
},
|
||||
{
|
||||
"id": "iJIqi7ngpGHWAqtgdjgxB",
|
||||
"text": "Front-end > HTML > Accessibility"
|
||||
},
|
||||
{
|
||||
"id": "V5zucKEHnIPPjwHqsMPHF",
|
||||
"text": "Front-end > HTML > Forms and Validations"
|
||||
},
|
||||
{
|
||||
"id": "z8-556o-PaHXjlytrawaF",
|
||||
"text": "Front-end > HTML > Writing Semantic HTML"
|
||||
},
|
||||
{
|
||||
"id": "PCirR2QiFYO89Fm-Ev3o1",
|
||||
"text": "Front-end > HTML > Learn the basics"
|
||||
},
|
||||
{
|
||||
"id": "ZhJhf1M2OphYbEmduFq-9",
|
||||
"text": "Front-end > CSS"
|
||||
},
|
||||
{
|
||||
"id": "YFjzPKWDwzrgk2HUX952L",
|
||||
"text": "Front-end > CSS > Learn the basics"
|
||||
},
|
||||
{
|
||||
"id": "dXeYVMXv-3MRQ1ovOUuJW",
|
||||
"text": "Front-end > CSS > Making Layouts"
|
||||
},
|
||||
{
|
||||
"id": "TKtWmArHn7elXRJdG6lDQ",
|
||||
"text": "Front-end > CSS > Responsive Design"
|
||||
},
|
||||
{
|
||||
"id": "ODcfFEorkfJNupoQygM53",
|
||||
"text": "Front-end > JavaScript"
|
||||
},
|
||||
{
|
||||
"id": "wQSjQqwKHfn5RGPk34BWI",
|
||||
"text": "Front-end > JavaScript > Learn the Basics"
|
||||
},
|
||||
{
|
||||
"id": "0MAogsAID9R04R5TTO2Qa",
|
||||
"text": "Front-end > JavaScript > Learn DOM Manipulation"
|
||||
},
|
||||
{
|
||||
"id": "A4brX0efjZ0FFPTB4r6U0",
|
||||
"text": "Front-end > JavaScript > Fetch API / Ajax (XHR)"
|
||||
},
|
||||
{
|
||||
"id": "NIY7c4TQEEHx0hATu-k5C",
|
||||
"text": "Front-end > Version Control Systems"
|
||||
},
|
||||
{
|
||||
"id": "R_I4SGYqLk5zze5I1zS_E",
|
||||
"text": "Front-end > Version Control Systems > Git"
|
||||
},
|
||||
{
|
||||
"id": "MXnFhZlNB1zTsBFDyni9H",
|
||||
"text": "Front-end > VCS Hosting"
|
||||
},
|
||||
{
|
||||
"id": "DILBiQp7WWgSZ5hhtDW6A",
|
||||
"text": "Front-end > VCS Hosting > Bitbucket"
|
||||
},
|
||||
{
|
||||
"id": "zIoSJMX3cuzCgDYHjgbEh",
|
||||
"text": "Front-end > VCS Hosting > GitLab"
|
||||
},
|
||||
{
|
||||
"id": "qmTVMJDsEhNIkiwE_UTYu",
|
||||
"text": "Front-end > VCS Hosting > GitHub"
|
||||
}
|
||||
]
|
@@ -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;
|
||||
|
||||
@@ -183,5 +184,6 @@ const courses = roadmapData.courses || [];
|
||||
<RelatedRoadmaps roadmap={roadmapData} />
|
||||
</div>
|
||||
|
||||
<CheckSubscriptionVerification client:load />
|
||||
<div slot='changelog-banner'></div>
|
||||
</BaseLayout>
|
||||
|
34
src/pages/[roadmapId]/tree.json.ts
Normal file
34
src/pages/[roadmapId]/tree.json.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const roadmapJsons = import.meta.glob('/src/data/roadmaps/**/tree.json', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
return Object.keys(roadmapJsons).map((filePath) => {
|
||||
const filePathParts = filePath.split('/');
|
||||
const roadmapId = filePathParts?.[filePathParts.length - 2];
|
||||
|
||||
const treeJSON = roadmapJsons[filePath] as Record<string, any>;
|
||||
|
||||
return {
|
||||
params: {
|
||||
roadmapId,
|
||||
},
|
||||
props: {
|
||||
treeJSON: treeJSON?.default || {},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async function ({ params, request, props }) {
|
||||
return new Response(JSON.stringify(props.treeJSON), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
@@ -14,6 +14,7 @@ import {
|
||||
type BestPracticeFrontmatter,
|
||||
getAllBestPractices,
|
||||
} from '../../../lib/best-practice';
|
||||
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
@@ -136,6 +137,6 @@ const ogImageUrl = getOpenGraphImageUrl({
|
||||
/>
|
||||
|
||||
{bestPracticeData.isUpcoming && <UpcomingForm />}
|
||||
|
||||
<CheckSubscriptionVerification client:load />
|
||||
<div slot='changelog-banner'></div>
|
||||
</BaseLayout>
|
||||
|
Reference in New Issue
Block a user