Compare commits

...

43 Commits

Author SHA1 Message Date
Kamran Ahmed
10cafe591e Update navigations 2025-06-26 12:54:29 +01:00
Kamran Ahmed
1309b2fab3 Update answers 2025-06-25 23:01:37 +01:00
Kamran Ahmed
2420a207c3 Update placeholder 2025-06-25 22:56:44 +01:00
Kamran Ahmed
f79733d8b7 Merge branch 'feat/questions-chat' of github.com:kamranahmedse/developer-roadmap into feat/questions-chat 2025-06-25 17:14:34 +01:00
Kamran Ahmed
df14860a14 Update UI 2025-06-25 17:14:29 +01:00
Arik Chakma
0f8f6eea57 fix: roadmap regenerate 2025-06-25 21:41:38 +06:00
Arik Chakma
7329936822 wip 2025-06-25 21:21:06 +06:00
Arik Chakma
ef7397cf4a wip 2025-06-25 21:20:42 +06:00
Kamran Ahmed
a64587b836 Merge branch 'feat/questions-chat' of github.com:kamranahmedse/developer-roadmap into feat/questions-chat 2025-06-25 15:54:23 +01:00
Kamran Ahmed
1b6029a04e Improve UI 2025-06-25 15:54:19 +01:00
Arik Chakma
e9fd3f0a57 feat: node click message populate 2025-06-25 18:42:34 +06:00
Arik Chakma
288032cb78 fix: infinite loop issue 2025-06-25 18:23:42 +06:00
Arik Chakma
10e7ec386c fix: show preferences option only when questions exist 2025-06-25 03:30:41 +06:00
Kamran Ahmed
762ec5c93c Merge branch 'feat/questions-chat' of github.com:kamranahmedse/developer-roadmap into feat/questions-chat 2025-06-24 22:18:47 +01:00
Kamran Ahmed
eb4d969ae5 Fix responsiveness issue 2025-06-24 22:18:41 +01:00
Arik Chakma
79e274190f fix: hydration error 2025-06-25 00:38:10 +06:00
Arik Chakma
cca807248e wip 2025-06-25 00:32:08 +06:00
Arik Chakma
eee75bc923 Merge branch 'feat/questions-chat' of github.com:kamranahmedse/developer-roadmap into feat/questions-chat 2025-06-25 00:29:16 +06:00
Arik Chakma
423cc80e57 feat: roadmap actions 2025-06-25 00:28:52 +06:00
Kamran Ahmed
e0da80eef7 Update question answers functionality 2025-06-24 19:26:20 +01:00
Kamran Ahmed
aad48d98dc Improve preferences 2025-06-24 19:03:06 +01:00
Arik Chakma
83720b387c fix: ai roadmap regenerate 2025-06-25 00:01:34 +06:00
Arik Chakma
b0c3b1505c wip 2025-06-24 23:48:59 +06:00
Arik Chakma
ae681a58b8 wip 2025-06-24 23:20:36 +06:00
Kamran Ahmed
ab761c792e Update question answers functionality 2025-06-24 18:16:51 +01:00
Kamran Ahmed
6a83882eae UI for chat 2025-06-24 18:11:39 +01:00
Kamran Ahmed
06c12f4d72 Refresh button not working 2025-06-24 18:07:21 +01:00
Kamran Ahmed
413748a224 Update question answer chat 2025-06-24 18:03:24 +01:00
Kamran Ahmed
8430f177f0 Merge branch 'feat/questions-chat' of github.com:kamranahmedse/developer-roadmap into feat/questions-chat 2025-06-24 17:44:25 +01:00
Kamran Ahmed
460200ee5a Update content generator 2025-06-24 17:44:18 +01:00
Kamran Ahmed
e8f5a06676 Add question answers 2025-06-24 17:43:12 +01:00
Arik Chakma
73e3b955f2 wip 2025-06-24 22:38:46 +06:00
Kamran Ahmed
d840d7e27d Merge branch 'master' of github.com:kamranahmedse/developer-roadmap into feat/questions-chat 2025-06-24 17:24:49 +01:00
Arik Chakma
0af6f9e987 wip 2025-06-24 21:39:53 +06:00
Arik Chakma
ae790470fe wip: ai roadmap 2025-06-24 21:05:56 +06:00
Arik Chakma
1c73ab3c1d wip 2025-06-23 23:10:14 +06:00
Arik Chakma
06db9a98d0 wip 2025-06-23 20:13:10 +06:00
Arik Chakma
71c147a0ef wip 2025-06-23 19:36:16 +06:00
Arik Chakma
f17f4b1403 wip 2025-06-20 19:29:56 +06:00
Arik Chakma
ea1df049a5 wip 2025-06-20 18:32:22 +06:00
Arik Chakma
2364eb9725 wip 2025-06-20 02:26:43 +06:00
Arik Chakma
0479911df5 wip 2025-06-20 01:45:09 +06:00
Arik Chakma
25967a85e1 wip 2025-06-20 01:22:56 +06:00
53 changed files with 2933 additions and 949 deletions

View File

@@ -11,30 +11,15 @@ type AIGuideCardProps = {
export function AIGuideCard(props: AIGuideCardProps) {
const { guide, showActions = true } = props;
const guideDepthColor =
{
essentials: 'text-green-700',
detailed: 'text-blue-700',
complete: 'text-purple-700',
}[guide.depth] || 'text-gray-700';
return (
<div className="relative flex flex-grow flex-col">
<a
href={`/ai/guide/${guide.slug}`}
className="group relative flex h-full min-h-[120px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-3 text-left hover:border-gray-300 hover:bg-gray-50 sm:p-4"
>
<div className="mb-2 flex items-center justify-between sm:mb-3">
<span
className={`rounded-full text-xs font-medium capitalize opacity-80 ${guideDepthColor}`}
>
{guide.depth}
</span>
</div>
<div className="relative max-h-[180px] min-h-[140px] overflow-y-hidden sm:max-h-[200px] sm:min-h-[160px]">
<div
className="prose prose-sm prose-pre:bg-gray-100 [&_h1]:hidden [&_h1:first-child]:block [&_h1:first-child]:text-base [&_h1:first-child]:font-bold [&_h1:first-child]:leading-[1.35] [&_h1:first-child]:text-pretty sm:[&_h1:first-child]:text-lg [&_h2]:hidden [&_h3]:hidden [&_h4]:hidden [&_h5]:hidden [&_h6]:hidden"
className="prose prose-sm prose-pre:bg-gray-100 [&_h1]:hidden [&_h1:first-child]:block [&_h1:first-child]:text-base [&_h1:first-child]:leading-[1.35] [&_h1:first-child]:font-bold [&_h1:first-child]:text-pretty sm:[&_h1:first-child]:text-lg [&_h2]:hidden [&_h3]:hidden [&_h4]:hidden [&_h5]:hidden [&_h6]:hidden"
dangerouslySetInnerHTML={{ __html: guide.html }}
/>

View File

@@ -5,7 +5,7 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { LibraryTabs } from '../Library/LibraryTab';
type AILibraryLayoutProps = {
activeTab: 'courses' | 'guides';
activeTab: 'courses' | 'guides' | 'roadmaps';
children: React.ReactNode;
};
@@ -22,7 +22,7 @@ export function AILibraryLayout(props: AILibraryLayoutProps) {
<div className="mx-auto flex w-full max-w-6xl flex-grow flex-col p-2">
<AITutorHeader
title="Library"
subtitle="Explore your AI-generated guides and courses"
subtitle="Explore your AI-generated guides, courses and roadmaps"
onUpgradeClick={() => setShowUpgradePopup(true)}
/>

View File

@@ -0,0 +1,58 @@
@font-face {
font-family: 'balsamiq';
src: url('/fonts/balsamiq.woff2');
}
svg text tspan {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeSpeed;
}
svg > g[data-type='topic'],
svg > g[data-type='subtopic'],
svg > g > g[data-type='link-item'],
svg > g[data-type='button'] {
cursor: pointer;
}
svg > g[data-type='topic']:hover > rect {
fill: #d6d700;
}
svg > g[data-type='subtopic']:hover > rect {
fill: #f3c950;
}
svg > g[data-type='button']:hover {
opacity: 0.8;
}
svg .done rect {
fill: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
}
svg > g[data-type='topic'].learning > rect + text,
svg > g[data-type='topic'].done > rect + text {
fill: black;
}
svg > g[data-type='subtipic'].done > rect + text,
svg > g[data-type='subtipic'].learning > rect + text {
fill: #cbcbcb;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .learning text {
text-decoration: underline;
}
svg .skipped rect {
fill: #496b69 !important;
}

View File

@@ -0,0 +1,167 @@
import './AIRoadmap.css';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useToast } from '../../hooks/use-toast';
import { queryClient } from '../../stores/query-client';
import { AITutorLayout } from '../AITutor/AITutorLayout';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap';
import { GenerateAIRoadmap } from './GenerateAIRoadmap';
import { AIRoadmapContent, type RoadmapNodeDetails } from './AIRoadmapContent';
import { AIRoadmapChat } from './AIRoadmapChat';
import { AlertCircleIcon } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { billingDetailsOptions } from '../../queries/billing';
export type AIRoadmapChatActions = {
handleNodeClick: (node: RoadmapNodeDetails) => void;
};
type AIRoadmapProps = {
roadmapSlug?: string;
};
export function AIRoadmap(props: AIRoadmapProps) {
const { roadmapSlug: defaultRoadmapSlug } = props;
const [roadmapSlug, setRoadmapSlug] = useState(defaultRoadmapSlug);
const toast = useToast();
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const [regeneratedSvgHtml, setRegeneratedSvgHtml] = useState<string | null>(
null,
);
const aiChatActionsRef = useRef<AIRoadmapChatActions | null>(null);
// only fetch the guide if the guideSlug is provided
// otherwise we are still generating the guide
const {
data: aiRoadmap,
isLoading: isLoadingBySlug,
error: aiRoadmapError,
} = useQuery(aiRoadmapOptions(roadmapSlug), queryClient);
const {
data: tokenUsage,
isLoading: isTokenUsageLoading,
refetch: refetchTokenUsage,
} = useQuery(getAiCourseLimitOptions(), queryClient);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const handleRegenerate = async (prompt?: string) => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (!isPaidUser && isLimitExceeded) {
setShowUpgradeModal(true);
return;
}
flushSync(() => {
setIsRegenerating(true);
setRegeneratedSvgHtml(null);
});
queryClient.cancelQueries(aiRoadmapOptions(roadmapSlug));
queryClient.setQueryData(aiRoadmapOptions(roadmapSlug).queryKey, (old) => {
if (!old) {
return old;
}
return {
...old,
data: '',
svgHtml: '',
};
});
setRegeneratedSvgHtml('');
await generateAIRoadmap({
roadmapSlug: aiRoadmap?.slug || '',
term: aiRoadmap?.term || '',
prompt,
isForce: true,
onStreamingChange: setIsRegenerating,
onRoadmapSvgChange: (svg) => {
setRegeneratedSvgHtml(svg.outerHTML);
},
onError: (error) => {
toast.error(error);
},
onFinish: () => {
setIsRegenerating(false);
refetchTokenUsage();
queryClient.invalidateQueries(aiRoadmapOptions(roadmapSlug));
},
});
};
const isLoading =
isLoadingBySlug ||
isRegenerating ||
isTokenUsageLoading ||
isBillingDetailsLoading;
const handleNodeClick = useCallback(
(node: RoadmapNodeDetails) => {
aiChatActionsRef.current?.handleNodeClick(node);
},
[aiChatActionsRef],
);
return (
<AITutorLayout
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden relative bg-white"
containerClassName="h-[calc(100vh-49px)] overflow-hidden relative"
>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
{!isLoading && aiRoadmapError && (
<div className="absolute inset-0 z-10 flex h-full flex-col items-center justify-center bg-white">
<div className="flex flex-col items-center justify-center gap-2">
<AlertCircleIcon className="size-10 text-gray-500" />
<p className="text-center">
{aiRoadmapError?.message || 'Something went wrong'}
</p>
</div>
</div>
)}
<div className="grow overflow-y-auto p-4 pt-0">
{roadmapSlug && !aiRoadmapError && (
<AIRoadmapContent
svgHtml={regeneratedSvgHtml ?? aiRoadmap?.svgHtml ?? ''}
isLoading={isLoading}
onRegenerate={handleRegenerate}
roadmapSlug={roadmapSlug}
onNodeClick={handleNodeClick}
/>
)}
{!roadmapSlug && !aiRoadmapError && (
<GenerateAIRoadmap onRoadmapSlugChange={setRoadmapSlug} />
)}
</div>
<AIRoadmapChat
roadmapSlug={roadmapSlug}
isRoadmapLoading={!aiRoadmap}
onUpgrade={() => setShowUpgradeModal(true)}
aiChatActionsRef={aiChatActionsRef}
/>
</AITutorLayout>
);
}

View File

@@ -0,0 +1,116 @@
import { MoreVertical, Play, Trash2 } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import { useToast } from '../../hooks/use-toast';
import { useMutation } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { httpDelete } from '../../lib/query-http';
type AIRoadmapActionsType = {
roadmapSlug: string;
onDeleted?: () => void;
};
export function AIRoadmapActions(props: AIRoadmapActionsType) {
const { roadmapSlug, onDeleted } = props;
const toast = useToast();
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const { mutate: deleteRoadmap, isPending: isDeleting } = useMutation(
{
mutationFn: async () => {
return httpDelete(`/v1-delete-ai-roadmap/${roadmapSlug}`);
},
onSuccess: () => {
toast.success('Roadmap deleted');
queryClient.invalidateQueries({
predicate: (query) => query.queryKey?.[0] === 'user-ai-roadmaps',
});
onDeleted?.();
},
onError: (error) => {
toast.error(error?.message || 'Failed to delete roadmap');
},
},
queryClient,
);
useOutsideClick(dropdownRef, () => {
setIsOpen(false);
});
useKeydown('Escape', () => {
setIsOpen(false);
});
return (
<div className="relative h-full" ref={dropdownRef}>
<button
className="h-full text-gray-400 hover:text-gray-700"
onClick={(e) => {
e.stopPropagation();
setIsOpen(!isOpen);
}}
>
<MoreVertical size={16} />
</button>
{isOpen && (
<div className="absolute top-8 right-0 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
<a
href={`/ai-roadmaps/${roadmapSlug}`}
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
>
<Play className="h-3.5 w-3.5" />
Visit Roadmap
</a>
{!isConfirming && (
<button
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
onClick={() => setIsConfirming(true)}
disabled={isDeleting}
>
{!isDeleting ? (
<>
<Trash2 className="h-3.5 w-3.5" />
Delete Roadmap
</>
) : (
'Deleting...'
)}
</button>
)}
{isConfirming && (
<span className="flex w-full items-center justify-between gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70">
Are you sure?
<div className="flex items-center gap-2">
<button
onClick={() => {
setIsConfirming(false);
deleteRoadmap();
}}
disabled={isDeleting}
className="text-red-500 underline hover:text-red-800"
>
Yes
</button>
<button
onClick={() => setIsConfirming(false)}
className="text-red-500 underline hover:text-red-800"
>
No
</button>
</div>
</span>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { CalendarIcon } from 'lucide-react';
import { getRelativeTimeString } from '../../lib/date';
import { cn } from '../../lib/classname';
import type { AIRoadmapDocument } from '../../queries/ai-roadmap';
import { AIRoadmapActions } from './AIRoadmapActions';
type AIRoadmapCardProps = {
roadmap: Omit<AIRoadmapDocument, 'data' | 'questionAndAnswers'>;
variant?: 'row' | 'column';
showActions?: boolean;
};
export function AIRoadmapCard(props: AIRoadmapCardProps) {
const { roadmap, variant = 'row', showActions = true } = props;
const updatedAgo = getRelativeTimeString(roadmap?.updatedAt);
return (
<div className="relative flex flex-grow">
<a
href={`/ai-roadmaps/${roadmap.slug}`}
className={cn(
'group relative flex h-full w-full gap-3 overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:border-gray-300 hover:bg-gray-50 sm:gap-4',
variant === 'column' && 'flex-col',
variant === 'row' && 'sm:flex-col sm:items-start',
)}
>
<div className="min-w-0 flex-1">
<h3 className="line-clamp-2 text-base font-semibold text-balance text-gray-900">
{roadmap.title}
</h3>
</div>
<div className="flex items-center gap-4 sm:gap-4">
<div className="items-center text-xs text-gray-600 flex">
<CalendarIcon className="mr-1 h-3.5 w-3.5" />
<span>{updatedAgo}</span>
</div>
</div>
</a>
{showActions && roadmap.slug && (
<div className="absolute top-2 right-2">
<AIRoadmapActions roadmapSlug={roadmap.slug} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,371 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type RefObject,
} from 'react';
import { useChat, type ChatMessage } from '../../hooks/use-chat';
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
import {
ArrowDownIcon,
BotIcon,
LockIcon,
MessageCircleIcon,
PauseCircleIcon,
SendIcon,
Trash2Icon,
XIcon,
} from 'lucide-react';
import { ChatHeaderButton } from '../FrameRenderer/RoadmapFloatingChat';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { flushSync } from 'react-dom';
import { markdownToHtml } from '../../lib/markdown';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { LoadingChip } from '../LoadingChip';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
import { useToast } from '../../hooks/use-toast';
import type { AIRoadmapChatActions } from './AIRoadmap';
import type { RoadmapNodeDetails } from './AIRoadmapContent';
type AIRoadmapChatProps = {
roadmapSlug?: string;
isRoadmapLoading?: boolean;
onUpgrade?: () => void;
aiChatActionsRef?: RefObject<AIRoadmapChatActions | null>;
};
export function AIRoadmapChat(props: AIRoadmapChatProps) {
const { roadmapSlug, isRoadmapLoading, onUpgrade, aiChatActionsRef } = props;
const toast = useToast();
const scrollareaRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState('');
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [isChatOpen, setIsChatOpen] = useState(true);
const [isMobile, setIsMobile] = useState(false);
const {
data: tokenUsage,
isLoading: isTokenUsageLoading,
refetch: refetchTokenUsage,
} = useQuery(getAiCourseLimitOptions(), queryClient);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const {
messages,
status,
streamedMessageHtml,
sendMessages,
setMessages,
stop,
} = useChat({
endpoint: `${import.meta.env.PUBLIC_API_URL}/v1-ai-roadmap-chat`,
onError: (error) => {
console.error(error);
toast.error(error?.message || 'Something went wrong');
},
data: {
aiRoadmapSlug: roadmapSlug,
},
onFinish: () => {
refetchTokenUsage();
},
});
const scrollToBottom = useCallback(
(behavior: 'smooth' | 'instant' = 'smooth') => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior,
});
},
[scrollareaRef],
);
const isStreamingMessage = status === 'streaming';
const hasMessages = messages.length > 0;
const handleSubmitInput = useCallback(
(defaultInputValue?: string) => {
const message = defaultInputValue || inputValue;
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (isStreamingMessage) {
return;
}
const newMessages: ChatMessage[] = [
...messages,
{
role: 'user',
content: message,
html: markdownToHtml(message),
},
];
flushSync(() => {
setMessages(newMessages);
});
sendMessages(newMessages);
setInputValue('');
setTimeout(() => {
scrollToBottom('smooth');
}, 0);
},
[inputValue, isStreamingMessage, messages, sendMessages, setMessages],
);
const checkScrollPosition = useCallback(() => {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollArea;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold
setShowScrollToBottom(!isAtBottom && messages.length > 0);
}, [messages.length]);
useEffect(() => {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
scrollArea.addEventListener('scroll', checkScrollPosition);
return () => scrollArea.removeEventListener('scroll', checkScrollPosition);
}, [checkScrollPosition]);
const isLoading =
isRoadmapLoading || isTokenUsageLoading || isBillingDetailsLoading;
useLayoutEffect(() => {
const deviceType = getTailwindScreenDimension();
const isMediumSize = ['sm', 'md'].includes(deviceType);
if (!isMediumSize) {
const storedState = localStorage.getItem('chat-history-sidebar-open');
setIsChatOpen(storedState === null ? true : storedState === 'true');
} else {
setIsChatOpen(!isMediumSize);
}
setIsMobile(isMediumSize);
}, []);
useEffect(() => {
if (!isMobile) {
localStorage.setItem('chat-history-sidebar-open', isChatOpen.toString());
}
}, [isChatOpen, isMobile]);
useImperativeHandle(aiChatActionsRef, () => ({
handleNodeClick: (node: RoadmapNodeDetails) => {
handleSubmitInput(`Explain what is "${node.nodeTitle}" topic in detail.`);
},
}));
if (!isChatOpen) {
return (
<div className="absolute inset-x-0 bottom-0 flex justify-center p-2">
<button
className="flex items-center justify-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow"
onClick={() => {
setIsChatOpen(true);
}}
>
<MessageCircleIcon className="h-4 w-4" />
<span className="text-sm">Open Chat</span>
</button>
</div>
);
}
return (
<div className="absolute inset-0 flex h-full w-full max-w-full flex-col overflow-hidden border-l border-gray-200 bg-white md:relative md:max-w-[40%]">
<div className="flex items-center justify-between gap-2 border-b border-gray-200 bg-white p-2">
<h2 className="flex items-center gap-2 text-sm font-medium">
<BotIcon className="h-4 w-4" />
AI Roadmap
</h2>
<button
className="mr-2 flex size-5 items-center justify-center rounded-md text-gray-500 hover:bg-gray-300 md:hidden"
onClick={() => {
setIsChatOpen(false);
}}
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
<LoadingChip message="Loading..." />
</div>
)}
{!isLoading && (
<>
<div className="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">
<RoadmapAIChatCard
role="assistant"
html="Hello, how can I help you today?"
isIntro
/>
{messages.map((chat, index) => {
return (
<RoadmapAIChatCard key={`chat-${index}`} {...chat} />
);
})}
{status === 'streaming' && !streamedMessageHtml && (
<RoadmapAIChatCard role="assistant" html="Thinking..." />
)}
{status === 'streaming' && streamedMessageHtml && (
<RoadmapAIChatCard
role="assistant"
html={streamedMessageHtml}
/>
)}
</div>
</div>
</div>
</div>
{(hasMessages || showScrollToBottom) && (
<div className="flex flex-row justify-between gap-2 border-t border-gray-200 px-3 py-2">
<ChatHeaderButton
icon={<Trash2Icon className="h-3.5 w-3.5" />}
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
onClick={() => {
setMessages([]);
}}
>
Clear
</ChatHeaderButton>
{showScrollToBottom && (
<ChatHeaderButton
icon={<ArrowDownIcon className="h-3.5 w-3.5" />}
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
onClick={() => {
scrollToBottom('smooth');
}}
>
Scroll to bottom
</ChatHeaderButton>
)}
</div>
)}
<div className="relative flex items-center border-t border-gray-200 text-sm">
{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>
)}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (isStreamingMessage) {
return;
}
handleSubmitInput();
}
}}
placeholder="Ask me anything about this roadmap..."
className="w-full resize-none px-3 py-4 outline-none"
/>
<button
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-zinc-500 hover:text-black disabled:opacity-50"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (status !== 'idle') {
stop();
return;
}
handleSubmitInput();
}}
>
{isStreamingMessage ? (
<PauseCircleIcon className="h-4 w-4" />
) : (
<SendIcon className="h-4 w-4" />
)}
</button>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { cn } from '../../lib/classname';
import { AIRoadmapRegenerate } from './AIRoadmapRegenerate';
import { LoadingChip } from '../LoadingChip';
import { type MouseEvent, useCallback } from 'react';
export type RoadmapNodeDetails = {
nodeId: string;
nodeType: string;
targetGroup?: SVGElement;
nodeTitle?: string;
parentTitle?: string;
parentId?: string;
};
export function getNodeDetails(
svgElement: SVGElement,
): RoadmapNodeDetails | null {
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
const nodeId = targetGroup?.dataset?.nodeId;
const nodeType = targetGroup?.dataset?.type;
const nodeTitle = targetGroup?.dataset?.title;
const parentTitle = targetGroup?.dataset?.parentTitle;
const parentId = targetGroup?.dataset?.parentId;
if (!nodeId || !nodeType) return null;
return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle, parentId };
}
export const allowedClickableNodeTypes = [
'topic',
'subtopic',
'button',
'link-item',
];
type AIRoadmapContentProps = {
isLoading?: boolean;
svgHtml: string;
onRegenerate?: (prompt?: string) => void;
roadmapSlug?: string;
onNodeClick?: (node: RoadmapNodeDetails) => void;
};
export function AIRoadmapContent(props: AIRoadmapContentProps) {
const { isLoading, svgHtml, onRegenerate, roadmapSlug, onNodeClick } = props;
const handleNodeClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (isLoading) {
return;
}
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup, nodeTitle, parentTitle } =
getNodeDetails(target) || {};
if (
!nodeId ||
!nodeType ||
!allowedClickableNodeTypes.includes(nodeType) ||
!nodeTitle
)
return;
if (nodeType === 'button' || nodeType === 'link-item') {
const link = targetGroup?.dataset?.link || '';
const isExternalLink = link.startsWith('http');
if (isExternalLink) {
window.open(link, '_blank');
} else {
window.location.href = link;
}
return;
}
onNodeClick?.({
nodeId,
nodeType,
nodeTitle,
...(nodeType === 'subtopic' && { parentTitle }),
});
},
[isLoading, onNodeClick],
);
return (
<div
className={cn(
'relative mx-auto w-full max-w-7xl',
isLoading && 'min-h-full',
)}
>
<div
id="roadmap-container"
className="relative min-h-[400px] [&>svg]:mx-auto"
dangerouslySetInnerHTML={{ __html: svgHtml }}
onClick={handleNodeClick}
/>
{isLoading && !svgHtml && (
<div className="absolute inset-0 flex items-center justify-center">
<LoadingChip message="Please wait..." />
</div>
)}
{onRegenerate && !isLoading && roadmapSlug && (
<div className="absolute top-4 right-4">
<AIRoadmapRegenerate
onRegenerate={onRegenerate}
roadmapSlug={roadmapSlug}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,298 @@
import {
Loader2Icon,
PenSquare,
RefreshCcw,
SaveIcon,
SettingsIcon,
type LucideIcon,
} from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { ModifyCoursePrompt } from '../GenerateCourse/ModifyCoursePrompt';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { httpPost } from '../../lib/query-http';
import { aiRoadmapOptions } from '../../queries/ai-roadmap';
import { UpdatePreferences } from '../GenerateGuide/UpdatePreferences';
import { generateAIRoadmapFromText } from '@roadmapsh/editor';
import { useToast } from '../../hooks/use-toast';
import { showLoginPopup } from '../../lib/popup';
import { isLoggedIn } from '../../lib/jwt';
import { useAuth } from '../../hooks/use-auth';
type AIRoadmapRegenerateProps = {
onRegenerate: (prompt?: string) => void;
roadmapSlug: string;
};
export function AIRoadmapRegenerate(props: AIRoadmapRegenerateProps) {
const { onRegenerate, roadmapSlug } = props;
const toast = useToast();
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showPromptModal, setShowPromptModal] = useState(false);
const [showUpdatePreferencesModal, setShowUpdatePreferencesModal] =
useState(false);
const currentUser = useAuth();
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => setIsDropdownVisible(false));
const { data: aiRoadmap } = useQuery(
aiRoadmapOptions(roadmapSlug),
queryClient,
);
const { mutate: updatePreferences, isPending: isUpdating } = useMutation(
{
mutationFn: (questionAndAnswers: QuestionAnswerChatMessage[]) => {
return httpPost(`/v1-update-ai-roadmap-preferences/${roadmapSlug}`, {
questionAndAnswers,
});
},
onSuccess: (_, vars) => {
queryClient.setQueryData(
aiRoadmapOptions(roadmapSlug).queryKey,
(old) => {
if (!old) {
return old;
}
return {
...old,
questionAndAnswers: vars,
};
},
);
setShowUpdatePreferencesModal(false);
setIsDropdownVisible(false);
onRegenerate();
},
},
queryClient,
);
const handleSaveAIRoadmap = async () => {
const { nodes, edges } = generateAIRoadmapFromText(aiRoadmap?.data || '');
return httpPost<{
roadmapId: string;
roadmapSlug: string;
}>(`/v1-save-ai-roadmap/${aiRoadmap?._id}`, {
title: aiRoadmap?.term,
nodes: nodes.map((node) => ({
...node,
// To reset the width and height of the node
// so that it can be calculated based on the content in the editor
width: undefined,
height: undefined,
style: {
...node.style,
width: undefined,
height: undefined,
},
measured: {
width: undefined,
height: undefined,
},
})),
edges,
});
};
const { mutate: saveAIRoadmap, isPending: isSavingAIRoadmap } = useMutation(
{
mutationFn: handleSaveAIRoadmap,
onSuccess: (data) => {
if (!data?.roadmapId) {
toast.error('Something went wrong');
return;
}
window.location.href = `/r/${data?.roadmapSlug}`;
},
},
queryClient,
);
const { mutate: editAIRoadmap, isPending: isEditingAIRoadmap } = useMutation(
{
mutationFn: handleSaveAIRoadmap,
onSuccess: (data) => {
if (!data?.roadmapId) {
toast.error('Something went wrong');
return;
}
window.open(
`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${data?.roadmapId}`,
'_blank',
);
},
},
queryClient,
);
const isCurrentUserCreator = currentUser?.id === aiRoadmap?.userId;
const showUpdatePreferences =
aiRoadmap?.questionAndAnswers &&
aiRoadmap.questionAndAnswers.length > 0 &&
isCurrentUserCreator;
return (
<>
{showUpgradeModal && (
<UpgradeAccountModal
onClose={() => {
setShowUpgradeModal(false);
}}
/>
)}
{showPromptModal && (
<ModifyCoursePrompt
description="Pass additional information to the AI to generate a roadmap."
onClose={() => setShowPromptModal(false)}
onSubmit={(prompt) => {
setShowPromptModal(false);
onRegenerate(prompt);
}}
/>
)}
{showUpdatePreferencesModal && (
<UpdatePreferences
onClose={() => setShowUpdatePreferencesModal(false)}
questionAndAnswers={aiRoadmap?.questionAndAnswers}
term={aiRoadmap?.term || ''}
format="roadmap"
onUpdatePreferences={(questionAndAnswers) => {
updatePreferences(questionAndAnswers);
}}
isUpdating={isUpdating}
/>
)}
<div ref={ref} className="relative flex items-stretch">
<button
className={cn('rounded-md px-2.5 text-gray-400 hover:text-black', {
'text-black': isDropdownVisible,
})}
onClick={() => setIsDropdownVisible(!isDropdownVisible)}
>
<PenSquare className="text-current" size={16} strokeWidth={2.5} />
</button>
{isDropdownVisible && (
<div className="absolute top-full right-0 min-w-[190px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
{isCurrentUserCreator && (
<>
{showUpdatePreferences && (
<ActionButton
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsDropdownVisible(false);
setShowUpdatePreferencesModal(true);
}}
icon={SettingsIcon}
label="Update Preferences"
/>
)}
<ActionButton
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsDropdownVisible(false);
onRegenerate();
}}
icon={RefreshCcw}
label="Regenerate"
/>
<ActionButton
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsDropdownVisible(false);
setShowPromptModal(true);
}}
icon={PenSquare}
label="Modify Prompt"
/>
<hr className="my-1 border-gray-200" />
</>
)}
<ActionButton
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
saveAIRoadmap();
}}
icon={SaveIcon}
label="Start Learning"
isLoading={isSavingAIRoadmap}
/>
<ActionButton
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
editAIRoadmap();
}}
icon={PenSquare}
label="Edit in Editor"
isLoading={isEditingAIRoadmap}
/>
</div>
)}
</div>
</>
);
}
type ActionButtonProps = {
onClick: () => void;
isLoading?: boolean;
icon: LucideIcon;
label: string;
};
function ActionButton(props: ActionButtonProps) {
const { onClick, isLoading, icon: Icon, label } = props;
return (
<button
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
onClick={onClick}
disabled={isLoading}
>
{isLoading ? (
<Loader2Icon className="animate-spin" size={16} strokeWidth={2.5} />
) : (
<Icon size={16} className="text-gray-400" strokeWidth={2.5} />
)}
{label}
</button>
);
}

View File

@@ -0,0 +1,115 @@
import { useEffect, useRef, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import { isLoggedIn } from '../../lib/jwt';
import { queryClient } from '../../stores/query-client';
import { LoadingChip } from '../LoadingChip';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap';
import { AIRoadmapContent } from './AIRoadmapContent';
type GenerateAIRoadmapProps = {
onRoadmapSlugChange?: (roadmapSlug: string) => void;
};
export function GenerateAIRoadmap(props: GenerateAIRoadmapProps) {
const { onRoadmapSlugChange } = props;
const [isLoading, setIsLoading] = useState(true);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState('');
const [svgHtml, setSvgHtml] = useState('');
const [content, setContent] = useState('');
const svgRef = useRef<string | null>(null);
useEffect(() => {
const params = getUrlParams();
const paramsTerm = params?.term;
const paramsSrc = params?.src || 'search';
if (!paramsTerm) {
return;
}
let questionAndAnswers: QuestionAnswerChatMessage[] = [];
const sessionId = params?.id;
if (sessionId) {
questionAndAnswers = getQuestionAnswerChatMessages(sessionId);
}
handleGenerateDocument({
term: paramsTerm,
src: paramsSrc,
questionAndAnswers,
});
}, []);
const handleGenerateDocument = async (options: {
term: string;
isForce?: boolean;
prompt?: string;
src?: string;
questionAndAnswers?: QuestionAnswerChatMessage[];
}) => {
const { term, isForce, prompt, src, questionAndAnswers } = options;
if (!isLoggedIn()) {
window.location.href = '/ai';
return;
}
await generateAIRoadmap({
term,
isForce,
prompt,
questionAndAnswers,
onDetailsChange: (details) => {
const { roadmapId, roadmapSlug, title, userId } = details;
const aiRoadmapData = {
_id: roadmapId,
userId,
title,
term,
data: content,
questionAndAnswers,
viewCount: 0,
svgHtml: svgRef.current || '',
lastVisitedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
queryClient.setQueryData(
aiRoadmapOptions(roadmapSlug).queryKey,
aiRoadmapData,
);
onRoadmapSlugChange?.(roadmapSlug);
window.history.replaceState(null, '', `/ai-roadmaps/${roadmapSlug}`);
},
onLoadingChange: setIsLoading,
onError: setError,
onStreamingChange: setIsStreaming,
onRoadmapSvgChange: (svg) => {
const svgHtml = svg.outerHTML;
svgRef.current = svgHtml;
setSvgHtml(svgHtml);
},
});
};
if (error) {
return <div className="text-red-500">{error}</div>;
}
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingChip message="Please wait..." />
</div>
);
}
return <AIRoadmapContent isLoading={isLoading} svgHtml={svgHtml} />;
}

View File

@@ -0,0 +1,146 @@
import { useQuery } from '@tanstack/react-query';
import { BookOpen, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
import { queryClient } from '../../stores/query-client';
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { Pagination } from '../Pagination/Pagination';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { AICourseSearch } from '../GenerateCourse/AICourseSearch';
import {
listUserAiRoadmapsOptions,
type ListUserAiRoadmapsQuery,
} from '../../queries/ai-roadmap';
import { AIRoadmapCard } from './AIRoadmapCard';
export function UserRoadmapsList() {
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
const [pageState, setPageState] = useState<ListUserAiRoadmapsQuery>({
perPage: '21',
currPage: '1',
query: '',
});
const { data: userAiRoadmaps, isFetching: isUserAiRoadmapsLoading } =
useQuery(listUserAiRoadmapsOptions(pageState), queryClient);
useEffect(() => {
setIsInitialLoading(false);
}, [userAiRoadmaps]);
const roadmaps = userAiRoadmaps?.data ?? [];
useEffect(() => {
const queryParams = getUrlParams();
setPageState({
...pageState,
currPage: queryParams?.p || '1',
query: queryParams?.q || '',
});
}, []);
useEffect(() => {
if (pageState?.currPage !== '1' || pageState?.query !== '') {
setUrlParams({
p: pageState?.currPage || '1',
q: pageState?.query || '',
});
} else {
deleteUrlParam('p');
deleteUrlParam('q');
}
}, [pageState]);
const isUserAuthenticated = isLoggedIn();
const isAnyLoading = isUserAiRoadmapsLoading || isInitialLoading;
return (
<>
{showUpgradePopup && (
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
setPageState({
...pageState,
query: value,
currPage: '1',
});
}}
placeholder="Search Roadmaps..."
disabled={isAnyLoading}
/>
{isAnyLoading && (
<p className="mb-4 flex flex-row items-center gap-2 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
Loading your courses...
</p>
)}
{!isAnyLoading && (
<>
<p className="mb-4 text-sm text-gray-500">
{isUserAuthenticated
? `You have generated ${userAiRoadmaps?.totalCount} roadmaps so far.`
: 'Sign up or login to generate your first roadmap. Takes 2s to do so.'}
</p>
{isUserAuthenticated && !isAnyLoading && roadmaps.length > 0 && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
{roadmaps.map((roadmap) => (
<AIRoadmapCard variant="column" key={roadmap._id} roadmap={roadmap} />
))}
</div>
<Pagination
totalCount={userAiRoadmaps?.totalCount || 0}
totalPages={userAiRoadmaps?.totalPages || 0}
currPage={Number(userAiRoadmaps?.currPage || 1)}
perPage={Number(userAiRoadmaps?.perPage || 10)}
onPageChange={(page) => {
setPageState({ ...pageState, currPage: String(page) });
}}
className="rounded-lg border border-gray-200 bg-white p-4"
/>
</div>
)}
{!isAnyLoading && roadmaps.length === 0 && (
<AITutorTallMessage
title={
isUserAuthenticated ? 'No roadmaps found' : 'Sign up or login'
}
subtitle={
isUserAuthenticated
? "You haven't generated any roadmaps yet."
: 'Takes 2s to sign up and generate your first roadmap.'
}
icon={BookOpen}
buttonText={
isUserAuthenticated
? 'Create your first roadmap'
: 'Sign up or login'
}
onButtonClick={() => {
if (isUserAuthenticated) {
window.location.href = '/ai';
} else {
showLoginPopup();
}
}}
/>
)}
</>
)}
</>
);
}

View File

@@ -1,4 +1,12 @@
import { BookOpen, Compass, Plus, Star, X, Zap } from 'lucide-react';
import {
BookOpen,
Compass,
MessageCircle,
Plus,
Star,
X,
Zap,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { isLoggedIn } from '../../lib/jwt';
import { useIsPaidUser } from '../../queries/billing';
@@ -21,22 +29,22 @@ type AITutorSidebarProps = {
const sidebarItems = [
{
key: 'new',
label: 'New',
label: 'Create with AI',
href: '/ai',
icon: Plus,
},
{
key: 'chat',
label: 'Ask AI Tutor',
href: '/ai/chat',
icon: MessageCircle,
},
{
key: 'library',
label: 'Library',
label: 'My Learning',
href: '/ai/courses',
icon: BookOpen,
},
// {
// key: 'chat',
// label: 'AI Chat',
// href: '/ai/chat',
// icon: Bot,
// },
{
key: 'staff-picks',
label: 'Staff Picks',

View File

@@ -1,50 +1,70 @@
import {
BookOpenIcon,
FileTextIcon,
MapIcon,
SparklesIcon,
type LucideIcon,
} from 'lucide-react';
import { useEffect, useId, useState, type FormEvent } from 'react';
import { useEffect, useId, useState } from 'react';
import { FormatItem } from './FormatItem';
import { GuideOptions } from './GuideOptions';
import { FineTuneCourse } from '../GenerateCourse/FineTuneCourse';
import { CourseOptions } from './CourseOptions';
import {
clearFineTuneData,
getCourseFineTuneData,
getLastSessionId,
storeFineTuneData,
} from '../../lib/ai';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { useIsPaidUser } from '../../queries/billing';
import {
clearQuestionAnswerChatMessages,
storeQuestionAnswerChatMessages,
} from '../../lib/ai-questions';
import {
QuestionAnswerChat,
type QuestionAnswerChatMessage,
} from './QuestionAnswerChat';
import { useToast } from '../../hooks/use-toast';
import { cn } from '../../lib/classname';
import { getUrlParams } from '../../lib/browser';
import { useParams } from '../../hooks/use-params';
const allowedFormats = ['course', 'guide', 'roadmap'] as const;
type AllowedFormat = (typeof allowedFormats)[number];
export type AllowedFormat = (typeof allowedFormats)[number];
export function ContentGenerator() {
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
const params = useParams<{ format: AllowedFormat }>();
const toast = useToast();
const [title, setTitle] = useState('');
const [selectedFormat, setSelectedFormat] = useState<AllowedFormat>('course');
// guide options
const [depth, setDepth] = useState('essentials');
// course options
const [difficulty, setDifficulty] = useState('beginner');
useEffect(() => {
const isValidFormat = allowedFormats.find(
(format) => format.value === params.format,
);
// fine-tune options
if (isValidFormat) {
setSelectedFormat(isValidFormat.value);
} else {
setSelectedFormat('course');
}
}, [params.format]);
// question answer chat options
const [showFineTuneOptions, setShowFineTuneOptions] = useState(false);
const [about, setAbout] = useState('');
const [goal, setGoal] = useState('');
const [customInstructions, setCustomInstructions] = useState('');
const [questionAnswerChatMessages, setQuestionAnswerChatMessages] = useState<
QuestionAnswerChatMessage[]
>([]);
const titleFieldId = useId();
const fineTuneOptionsId = useId();
useEffect(() => {
const params = getUrlParams();
const format = params.format as AllowedFormat;
if (format && allowedFormats.includes(format)) {
setSelectedFormat(format);
}
}, []);
const allowedFormats: {
label: string;
icon: LucideIcon;
@@ -60,10 +80,14 @@ export function ContentGenerator() {
icon: FileTextIcon,
value: 'guide',
},
{
label: 'Roadmap',
icon: MapIcon,
value: 'roadmap',
},
];
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const handleSubmit = () => {
if (!isLoggedIn()) {
showLoginPopup();
return;
@@ -71,18 +95,17 @@ export function ContentGenerator() {
let sessionId = '';
if (showFineTuneOptions) {
clearFineTuneData();
sessionId = storeFineTuneData({
about,
goal,
customInstructions,
});
clearQuestionAnswerChatMessages();
sessionId = storeQuestionAnswerChatMessages(questionAnswerChatMessages);
}
const trimmedTitle = title.trim();
if (selectedFormat === 'course') {
window.location.href = `/ai/course?term=${encodeURIComponent(title)}&difficulty=${difficulty}&id=${sessionId}&format=${selectedFormat}`;
window.location.href = `/ai/course?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`;
} else if (selectedFormat === 'guide') {
window.location.href = `/ai/guide?term=${encodeURIComponent(title)}&depth=${depth}&id=${sessionId}&format=${selectedFormat}`;
window.location.href = `/ai/guide?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`;
} else if (selectedFormat === 'roadmap') {
window.location.href = `/ai/roadmap?term=${encodeURIComponent(trimmedTitle)}&id=${sessionId}&format=${selectedFormat}`;
}
};
@@ -94,24 +117,11 @@ export function ContentGenerator() {
});
}, []);
useEffect(() => {
const lastSessionId = getLastSessionId();
if (!lastSessionId) {
return;
}
const fineTuneData = getCourseFineTuneData(lastSessionId);
if (!fineTuneData) {
return;
}
setAbout(fineTuneData.about);
setGoal(fineTuneData.goal);
setCustomInstructions(fineTuneData.customInstructions);
}, []);
const trimmedTitle = title.trim();
const canGenerate = trimmedTitle && trimmedTitle.length >= 3;
return (
<div className="mx-auto flex w-full max-w-2xl flex-grow flex-col pt-4 md:justify-center md:pt-10 lg:pt-4">
<div className="mx-auto flex w-full max-w-2xl flex-grow flex-col pt-4 md:justify-center md:pt-10 lg:pt-28 lg:pb-24">
<div className="relative">
{isUpgradeModalOpen && (
<UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} />
@@ -135,7 +145,13 @@ export function ContentGenerator() {
</p>
</div>
<form className="mt-10 space-y-4" onSubmit={handleSubmit}>
<form
className="mt-10 space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div className="flex flex-col gap-2">
<label htmlFor={titleFieldId} className="inline-block text-gray-500">
What can I help you learn?
@@ -145,7 +161,10 @@ export function ContentGenerator() {
id={titleFieldId}
placeholder="Enter a topic"
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={(e) => {
setTitle(e.target.value);
setShowFineTuneOptions(false);
}}
className="block w-full rounded-xl border border-gray-200 bg-white p-4 outline-none placeholder:text-gray-500 focus:border-gray-500"
required
minLength={3}
@@ -155,7 +174,7 @@ export function ContentGenerator() {
<label className="inline-block text-gray-500">
Choose the format
</label>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
{allowedFormats.map((format) => {
const isSelected = format.value === selectedFormat;
@@ -172,53 +191,52 @@ export function ContentGenerator() {
</div>
</div>
{selectedFormat === 'guide' && (
<GuideOptions depth={depth} setDepth={setDepth} />
)}
<label
className={cn(
'flex cursor-pointer items-center gap-2 rounded-xl border border-gray-200 bg-white p-4 transition-all',
)}
htmlFor={fineTuneOptionsId}
>
<input
type="checkbox"
id={fineTuneOptionsId}
checked={showFineTuneOptions}
onChange={(e) => {
if (!trimmedTitle) {
toast.error('Please enter a topic first');
return;
}
{selectedFormat === 'course' && (
<CourseOptions
difficulty={difficulty}
setDifficulty={setDifficulty}
if (trimmedTitle.length < 3) {
toast.error('Topic must be at least 3 characters long');
return;
}
setShowFineTuneOptions(e.target.checked);
}}
/>
)}
<span className="max-sm:hidden">
Answer the following questions for a better {selectedFormat}
</span>
<span className="sm:hidden">Customize your {selectedFormat}</span>
</label>
{selectedFormat !== 'roadmap' && (
<>
<label
className={cn(
'flex items-center gap-2 border border-gray-200 bg-white p-4',
showFineTuneOptions && 'rounded-t-xl',
!showFineTuneOptions && 'rounded-xl',
)}
htmlFor={fineTuneOptionsId}
>
<input
type="checkbox"
id={fineTuneOptionsId}
checked={showFineTuneOptions}
onChange={(e) => setShowFineTuneOptions(e.target.checked)}
/>
Explain more for a better result
</label>
{showFineTuneOptions && (
<FineTuneCourse
hasFineTuneData={showFineTuneOptions}
about={about}
goal={goal}
customInstructions={customInstructions}
setAbout={setAbout}
setGoal={setGoal}
setCustomInstructions={setCustomInstructions}
className="-mt-4.5 overflow-hidden rounded-b-xl border border-gray-200 bg-white [&_div:first-child_label]:border-t-0"
/>
)}
</>
{showFineTuneOptions && (
<QuestionAnswerChat
term={title}
format={selectedFormat}
questionAnswerChatMessages={questionAnswerChatMessages}
setQuestionAnswerChatMessages={setQuestionAnswerChatMessages}
onGenerateNow={() => {
handleSubmit();
}}
/>
)}
<button
type="submit"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-black p-4 text-white focus:outline-none"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-black p-4 text-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-80"
disabled={!canGenerate}
>
<SparklesIcon className="size-4" />
Generate

View File

@@ -1,79 +0,0 @@
import { useId, useState } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../Select';
type CourseOptionsProps = {
difficulty: string;
setDifficulty: (difficulty: string) => void;
};
export function CourseOptions(props: CourseOptionsProps) {
const { difficulty, setDifficulty } = props;
const difficultySelectId = useId();
const difficultyOptions = [
{
label: 'Beginner',
value: 'beginner',
description: 'Covers fundamental concepts',
},
{
label: 'Intermediate',
value: 'intermediate',
description: 'Explore advanced topics',
},
{
label: 'Advanced',
value: 'advanced',
description: 'Deep dives into complex concepts',
},
];
const selectedDifficulty = difficultyOptions.find(
(option) => option.value === difficulty,
);
return (
<div className="flex flex-col gap-2">
<label
htmlFor={difficultySelectId}
className="inline-block text-gray-500"
>
Choose difficulty level
</label>
<Select value={difficulty} onValueChange={setDifficulty}>
<SelectTrigger
id={difficultySelectId}
className="h-auto rounded-xl bg-white p-4 text-base"
>
{selectedDifficulty && (
<div className="flex flex-col gap-1">
<span>{selectedDifficulty.label}</span>
</div>
)}
{!selectedDifficulty && (
<SelectValue placeholder="Select a difficulty" />
)}
</SelectTrigger>
<SelectContent className="rounded-xl bg-white">
{difficultyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-1">
<span>{option.label}</span>
<span className="text-xs text-gray-500">
{option.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -1,72 +0,0 @@
import { useId } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../Select';
type GuideOptionsProps = {
depth: string;
setDepth: (depth: string) => void;
};
export function GuideOptions(props: GuideOptionsProps) {
const { depth, setDepth } = props;
const depthSelectId = useId();
const depthOptions = [
{
label: 'Essentials',
value: 'essentials',
description: 'Just the core concepts',
},
{
label: 'Detailed',
value: 'detailed',
description: 'In-depth explanation',
},
{
label: 'Complete',
value: 'complete',
description: 'Cover the topic fully',
},
];
const selectedDepth = depthOptions.find((option) => option.value === depth);
return (
<div className="flex flex-col gap-2">
<label htmlFor={depthSelectId} className="inline-block text-gray-500">
Choose depth of content
</label>
<Select value={depth} onValueChange={setDepth}>
<SelectTrigger
id={depthSelectId}
className="h-auto rounded-xl bg-white p-4 text-base"
>
{selectedDepth && (
<div className="flex flex-col gap-1">
<span>{selectedDepth.label}</span>
</div>
)}
{!selectedDepth && <SelectValue placeholder="Select a depth" />}
</SelectTrigger>
<SelectContent className="rounded-xl bg-white">
{depthOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-1">
<span>{option.label}</span>
<span className="text-xs text-gray-500">
{option.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,363 @@
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import {
aiQuestionSuggestionsOptions,
type AIQuestionSuggestionsResponse,
} from '../../queries/user-ai-session';
import type { AllowedFormat } from './ContentGenerator';
import {
Loader2Icon,
RefreshCcwIcon,
SendIcon, Trash2
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/classname';
import { flushSync } from 'react-dom';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { Tooltip } from '../Tooltip';
export type QuestionAnswerChatMessage =
| { role: 'user'; answer: string }
| {
role: 'assistant';
question: string;
possibleAnswers: string[];
};
type QuestionAnswerChatProps = {
term: string;
format: AllowedFormat;
questionAnswerChatMessages: QuestionAnswerChatMessage[];
setQuestionAnswerChatMessages: (
messages: QuestionAnswerChatMessage[],
) => void;
onGenerateNow: () => void;
defaultQuestions?: AIQuestionSuggestionsResponse['questions'];
type?: 'create' | 'update';
className?: string;
};
export function QuestionAnswerChat(props: QuestionAnswerChatProps) {
const {
term,
format,
defaultQuestions,
questionAnswerChatMessages,
setQuestionAnswerChatMessages,
onGenerateNow,
type = 'create',
className = '',
} = props;
const [activeMessageIndex, setActiveMessageIndex] = useState(
defaultQuestions?.length ?? 0,
);
const [message, setMessage] = useState('');
const [status, setStatus] = useState<'answering' | 'done'>('answering');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const {
data: aiQuestionSuggestions,
isLoading: isLoadingAiQuestionSuggestions,
} = useQuery(
aiQuestionSuggestionsOptions({ term, format }, defaultQuestions),
queryClient,
);
const activeMessage = aiQuestionSuggestions?.questions[activeMessageIndex];
const scrollToBottom = () => {
if (!scrollAreaRef.current) {
return;
}
scrollAreaRef.current.scrollTo({
top: scrollAreaRef.current.scrollHeight,
behavior: 'instant',
});
};
const handleAnswerSelect = (answer: string) => {
const trimmedAnswer = answer.trim();
if (!activeMessage || !trimmedAnswer) {
return;
}
const newMessages: QuestionAnswerChatMessage[] = [
...questionAnswerChatMessages,
{
role: 'assistant',
...activeMessage,
},
{
role: 'user',
answer: trimmedAnswer,
},
];
setQuestionAnswerChatMessages(newMessages);
setMessage('');
const hasMoreMessages =
activeMessageIndex < aiQuestionSuggestions.questions.length - 1;
if (!hasMoreMessages) {
setStatus('done');
return;
}
flushSync(() => {
setActiveMessageIndex(activeMessageIndex + 1);
setStatus('answering');
// focus the input
inputRef.current?.focus();
});
scrollToBottom();
};
const canGenerateNow =
// user can generate after answering 5 questions -> 5 * 2 messages (user and assistant)
!isLoadingAiQuestionSuggestions && questionAnswerChatMessages.length >= 10;
const canReset = questionAnswerChatMessages.length >= 2;
const handleReset = () => {
setQuestionAnswerChatMessages([]);
setActiveMessageIndex(0);
setStatus('answering');
};
const handleEditMessage = (messageIndex: number) => {
// Remove the assistant question and user answer pair
// Since user messages are at odd indices, we want to remove both
// the assistant message (at messageIndex - 1) and the user message (at messageIndex)
const assistantMessageIndex = messageIndex - 1;
const newMessages = questionAnswerChatMessages.slice(
0,
assistantMessageIndex,
);
setQuestionAnswerChatMessages(newMessages);
// Calculate which question should be active
// Since we removed both assistant and user messages, the question index
// is simply assistantMessageIndex / 2
const questionIndex = Math.floor(assistantMessageIndex / 2);
setActiveMessageIndex(questionIndex);
setStatus('answering');
setMessage('');
setTimeout(() => {
inputRef.current?.focus();
}, 0);
};
useEffect(() => {
scrollToBottom();
}, [defaultQuestions, type]);
return (
<>
<div
className={cn(
'relative h-[350px] w-full overflow-hidden rounded-xl border border-gray-200 bg-white',
className,
)}
>
{isLoadingAiQuestionSuggestions && (
<div className="absolute inset-0 flex items-center justify-center bg-white">
<div className="flex animate-pulse items-center gap-2 rounded-full border border-gray-200 bg-gray-50 p-2 px-4 text-sm">
<Loader2Icon className="size-4 animate-spin" />
<span>Generating personalized questions...</span>
</div>
</div>
)}
{!isLoadingAiQuestionSuggestions && status === 'done' && (
<div className="absolute inset-0 flex items-center justify-center bg-white">
<div className="flex flex-col items-center">
<CheckIcon additionalClasses="size-12" />
<p className="mt-3 text-lg font-semibold">Preferences saved</p>
<p className="text-sm text-gray-500">
You can now start generating {format}
</p>
<button
className="mt-4 flex items-center gap-2 rounded-lg bg-gray-100 px-4 py-1.5 text-sm text-gray-500 hover:bg-gray-200 focus:outline-none"
onClick={handleReset}
>
<RefreshCcwIcon className="size-3.5" />
Reanswer questions
</button>
</div>
</div>
)}
{!isLoadingAiQuestionSuggestions && status === 'answering' && (
<>
<div className="flex h-full w-full flex-col bg-white">
<div
ref={scrollAreaRef}
className="relative h-full w-full grow overflow-y-auto"
>
<div className="absolute inset-0 flex flex-col">
<div className="flex w-full grow flex-col justify-end gap-2 p-2">
{questionAnswerChatMessages.map((message, index) => (
<QuestionAnswerChatMessage
key={index}
role={message.role}
question={
message.role === 'assistant'
? message.question
: undefined
}
answer={
message.role === 'user' ? message.answer : undefined
}
onEdit={
message.role === 'user'
? () => handleEditMessage(index)
: undefined
}
/>
))}
{activeMessage && (
<QuestionAnswerChatMessage
role="assistant"
question={activeMessage?.question ?? ''}
possibleAnswers={activeMessage.possibleAnswers}
onAnswerSelect={handleAnswerSelect}
/>
)}
</div>
</div>
</div>
{!activeMessage && type === 'update' && (
<div className="p-2">
<button
className="flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-gray-200 p-2 hover:bg-gray-300"
onClick={() => {
setQuestionAnswerChatMessages([]);
setActiveMessageIndex(0);
setStatus('answering');
}}
>
<Trash2 className="size-4" />
Reanswer all questions
</button>
</div>
)}
{activeMessage && (
<div className="p-2">
<div className="rounded-lg border border-gray-200 bg-white">
<div className="flex w-full items-center justify-between gap-2 p-2">
<input
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full bg-transparent text-sm focus:outline-none"
placeholder={activeMessage.possibleAnswers ? "Type your answer..." : "Or type your own answer..."}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAnswerSelect(message);
setMessage('');
}
}}
/>
<button
type="button"
className="flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-gray-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={!message}
onClick={() => {
handleAnswerSelect(message);
setMessage('');
}}
>
<SendIcon className="size-4" />
</button>
</div>
</div>
</div>
)}
</div>
</>
)}
</div>
</>
);
}
type QuestionAnswerChatMessageProps = {
role: 'user' | 'assistant';
question?: string;
answer?: string;
possibleAnswers?: string[];
onAnswerSelect?: (answer: string) => void;
onEdit?: () => void;
};
function QuestionAnswerChatMessage(props: QuestionAnswerChatMessageProps) {
const { role, question, answer, possibleAnswers, onAnswerSelect, onEdit } =
props;
const hasAnswers = possibleAnswers && possibleAnswers.length > 0;
return (
<div
className={cn(
'flex w-fit items-center gap-2 rounded-lg border p-2 text-pretty',
role === 'user' && 'self-end border-gray-200 bg-gray-300/30',
role === 'assistant' && 'border-yellow-200 bg-yellow-300/30',
)}
>
{role === 'assistant' && (
<div className="text-sm">
<div className={cn(hasAnswers && 'mb-2')}>{question}</div>
{hasAnswers && onAnswerSelect && (
<div className="mt-2">
<div className="flex flex-wrap gap-1.5">
{possibleAnswers.map((answer) => (
<button
type="button"
key={answer}
className="cursor-pointer rounded-md border border-yellow-300/50 bg-white/70 px-2 py-1 text-xs text-gray-700 hover:bg-white hover:shadow-sm focus:outline-none"
onClick={() => {
onAnswerSelect(answer);
}}
>
{answer}
</button>
))}
</div>
</div>
)}
</div>
)}
{role === 'user' && (
<div className="flex items-center gap-2">
<div className="text-sm">{answer}</div>
{onEdit && (
<div className="group relative">
<button
type="button"
className="flex size-6 shrink-0 items-center justify-center rounded-md opacity-70 hover:bg-gray-100 hover:opacity-100 focus:outline-none text-gray-500"
onClick={onEdit}
>
<Trash2 className="size-4" />
</button>
<Tooltip additionalClass="-translate-y-2" position="top-right">
Reanswer after this point
</Tooltip>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -46,15 +46,24 @@ export function AICourseCard(props: AICourseCardProps) {
>
{/* Title and difficulty section */}
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
>
{course.difficulty}
</span>
</div>
{course.difficulty && (
<div className="mb-1 flex items-center gap-2">
<span
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
>
{course.difficulty}
</span>
</div>
)}
<h3 className="line-clamp-2 text-base font-semibold text-balance text-gray-900">
<h3
className={cn(
'line-clamp-2 text-base font-semibold text-balance text-gray-900',
{
'max-w-[95%]': variant === 'column',
},
)}
>
{course.title
?.replace(": A Beginner's Guide", '')
?.replace(' for beginners', '')

View File

@@ -256,12 +256,14 @@ export function AICourseContent(props: AICourseContentProps) {
</span>
</a>
<div className="flex items-center gap-2">
<div className="flex flex-row lg:hidden">
<AICourseLimit
onUpgrade={() => setShowUpgradeModal(true)}
onShowLimits={() => setShowAILimitsPopup(true)}
/>
</div>
{!isLoading && (
<div className="flex flex-row lg:hidden">
<AICourseLimit
onUpgrade={() => setShowUpgradeModal(true)}
onShowLimits={() => setShowAILimitsPopup(true)}
/>
</div>
)}
{viewMode === 'module' && (
<button
@@ -326,14 +328,16 @@ export function AICourseContent(props: AICourseContentProps) {
</div>
</div>
</div>
<div className="flex gap-2">
<div className="hidden gap-2 lg:flex">
<AICourseLimit
onUpgrade={() => setShowUpgradeModal(true)}
onShowLimits={() => setShowAILimitsPopup(true)}
/>
{!isLoading && (
<div className="flex gap-2">
<div className="hidden gap-2 lg:flex">
<AICourseLimit
onUpgrade={() => setShowUpgradeModal(true)}
onShowLimits={() => setShowAILimitsPopup(true)}
/>
</div>
</div>
</div>
)}
</header>
<div className="flex flex-1 overflow-hidden">
@@ -499,6 +503,7 @@ export function AICourseContent(props: AICourseContentProps) {
onForkCourse={() => {
setIsForkingCourse(true);
}}
courseSlug={courseSlug!}
/>
)}

View File

@@ -12,6 +12,7 @@ type AICourseOutlineHeaderProps = {
setViewMode: (mode: AICourseViewMode) => void;
isForkable: boolean;
onForkCourse: () => void;
courseSlug: string;
};
export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
@@ -23,6 +24,7 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
setViewMode,
isForkable,
onForkCourse,
courseSlug,
} = props;
return (
@@ -36,9 +38,6 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
<h2 className="mb-1 text-2xl font-bold text-balance max-lg:text-lg max-lg:leading-tight">
{course.title || 'Loading course ..'}
</h2>
<p className="text-sm text-gray-500 capitalize">
{course.title ? course.difficulty : 'Please wait ..'}
</p>
</div>
<div className="absolute top-3 right-3 flex gap-2 max-lg:relative max-lg:top-0 max-lg:right-0 max-lg:w-full max-lg:flex-row-reverse max-lg:justify-between">
@@ -48,6 +47,7 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
onRegenerateOutline={onRegenerateOutline}
isForkable={isForkable}
onForkCourse={onForkCourse}
courseSlug={courseSlug}
/>
<div className="mr-1 flex rounded-lg border border-gray-200 bg-white p-0.5">
<button

View File

@@ -19,6 +19,7 @@ type AICourseOutlineViewProps = {
viewMode: AICourseViewMode;
isForkable: boolean;
onForkCourse: () => void;
courseSlug: string;
};
export function AICourseOutlineView(props: AICourseOutlineViewProps) {
@@ -34,6 +35,7 @@ export function AICourseOutlineView(props: AICourseOutlineViewProps) {
viewMode,
isForkable,
onForkCourse,
courseSlug,
} = props;
const aiCourseProgress = course.done || [];
@@ -48,6 +50,7 @@ export function AICourseOutlineView(props: AICourseOutlineViewProps) {
setViewMode={setViewMode}
isForkable={isForkable}
onForkCourse={onForkCourse}
courseSlug={courseSlug}
/>
{course.title ? (
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4">

View File

@@ -229,6 +229,7 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
setViewMode={setViewMode}
isForkable={isForkable}
onForkCourse={onForkCourse}
courseSlug={courseSlug}
/>
{isLoading && (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">

View File

@@ -1,22 +1,19 @@
import { useEffect, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import { isLoggedIn } from '../../lib/jwt';
import { getCourseFineTuneData, type AiCourse } from '../../lib/ai';
import type { AiCourse } from '../../lib/ai';
import { AICourseContent } from './AICourseContent';
import { generateCourse } from '../../helper/generate-ai-course';
import { useQuery } from '@tanstack/react-query';
import { getAiCourseOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
type GenerateAICourseProps = {};
export function GenerateAICourse(props: GenerateAICourseProps) {
const [term, setTerm] = useState('');
const [difficulty, setDifficulty] = useState('');
const [sessionId, setSessionId] = useState('');
const [goal, setGoal] = useState('');
const [about, setAbout] = useState('');
const [customInstructions, setCustomInstructions] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
@@ -27,7 +24,6 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
const [course, setCourse] = useState<AiCourse>({
title: '',
modules: [],
difficulty: '',
done: [],
});
@@ -46,71 +42,39 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
}, [aiCourse]);
useEffect(() => {
if (term || difficulty) {
if (term) {
return;
}
const params = getUrlParams();
const paramsTerm = params?.term;
const paramsDifficulty = params?.difficulty;
const paramsSrc = params?.src || 'search';
if (!paramsTerm || !paramsDifficulty) {
if (!paramsTerm) {
return;
}
setTerm(paramsTerm);
setDifficulty(paramsDifficulty);
const sessionId = params?.id;
setSessionId(sessionId);
let paramsGoal = '';
let paramsAbout = '';
let paramsCustomInstructions = '';
let questionAndAnswers: QuestionAnswerChatMessage[] = [];
if (sessionId) {
const fineTuneData = getCourseFineTuneData(sessionId);
if (fineTuneData) {
paramsGoal = fineTuneData.goal;
paramsAbout = fineTuneData.about;
paramsCustomInstructions = fineTuneData.customInstructions;
setGoal(paramsGoal);
setAbout(paramsAbout);
setCustomInstructions(paramsCustomInstructions);
}
questionAndAnswers = getQuestionAnswerChatMessages(sessionId);
}
handleGenerateCourse({
term: paramsTerm,
difficulty: paramsDifficulty,
instructions: paramsCustomInstructions,
goal: paramsGoal,
about: paramsAbout,
src: paramsSrc,
questionAndAnswers,
});
}, [term, difficulty]);
}, [term]);
const handleGenerateCourse = async (options: {
term: string;
difficulty: string;
instructions?: string;
goal?: string;
about?: string;
isForce?: boolean;
prompt?: string;
src?: string;
questionAndAnswers?: QuestionAnswerChatMessage[];
}) => {
const {
term,
difficulty,
isForce,
prompt,
instructions,
goal,
about,
src,
} = options;
const { term, isForce, prompt, src, questionAndAnswers } = options;
if (!isLoggedIn()) {
window.location.href = '/ai';
@@ -119,7 +83,6 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
await generateCourse({
term,
difficulty,
slug: courseSlug,
onCourseIdChange: setCourseId,
onCourseSlugChange: setCourseSlug,
@@ -127,40 +90,13 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
onCourseChange: setCourse,
onLoadingChange: setIsLoading,
onError: setError,
instructions,
goal,
about,
questionAndAnswers,
isForce,
prompt,
src,
});
};
useEffect(() => {
const handlePopState = (e: PopStateEvent) => {
const { courseId, courseSlug, term, difficulty } = e.state || {};
if (!courseId || !courseSlug) {
window.location.reload();
return;
}
setCourseId(courseId);
setCourseSlug(courseSlug);
setTerm(term);
setDifficulty(difficulty);
setIsLoading(true);
handleGenerateCourse({ term, difficulty }).finally(() => {
setIsLoading(false);
});
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, []);
return (
<AICourseContent
courseSlug={courseSlug}
@@ -171,7 +107,6 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
onRegenerateOutline={(prompt) => {
handleGenerateCourse({
term,
difficulty,
isForce: true,
prompt,
});

View File

@@ -59,7 +59,6 @@ export function GetAICourse(props: GetAICourseProps) {
await generateCourse({
term: aiCourse.keyword,
difficulty: aiCourse.difficulty,
slug: courseSlug,
prompt,
onCourseChange: (course, rawData) => {
@@ -68,7 +67,6 @@ export function GetAICourse(props: GetAICourseProps) {
{
...aiCourse,
title: course.title,
difficulty: course.difficulty,
modules: course.modules,
},
);
@@ -89,7 +87,6 @@ export function GetAICourse(props: GetAICourseProps) {
course={{
title: aiCourse?.title || '',
modules: aiCourse?.modules || [],
difficulty: aiCourse?.difficulty || 'Easy',
done: aiCourse?.done || [],
}}
isLoading={isLoading || isRegenerating}

View File

@@ -1,27 +1,73 @@
import { PenSquare, RefreshCcw } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { PenSquare, RefreshCcw, Settings2Icon } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
import { httpPost } from '../../lib/query-http';
import { getAiCourseOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { UpdatePreferences } from '../GenerateGuide/UpdatePreferences';
import { ModifyCoursePrompt } from './ModifyCoursePrompt';
type RegenerateOutlineProps = {
onRegenerateOutline: (prompt?: string) => void;
isForkable: boolean;
onForkCourse: () => void;
courseSlug: string;
};
export function RegenerateOutline(props: RegenerateOutlineProps) {
const { onRegenerateOutline, isForkable, onForkCourse } = props;
const { onRegenerateOutline, isForkable, onForkCourse, courseSlug } = props;
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showPromptModal, setShowPromptModal] = useState(false);
const [showUpdatePreferencesModal, setShowUpdatePreferencesModal] =
useState(false);
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => setIsDropdownVisible(false));
const { data: aiCourse } = useQuery(
getAiCourseOptions({ aiCourseSlug: courseSlug }),
queryClient,
);
const { mutate: updatePreferences, isPending: isUpdating } = useMutation(
{
mutationFn: (questionAndAnswers: QuestionAnswerChatMessage[]) => {
return httpPost(`/v1-update-ai-course-preferences/${courseSlug}`, {
questionAndAnswers,
});
},
onSuccess: (_, vars) => {
queryClient.setQueryData(
getAiCourseOptions({ aiCourseSlug: courseSlug }).queryKey,
(old) => {
if (!old) {
return old;
}
return {
...old,
questionAndAnswers: vars,
};
},
);
setShowUpdatePreferencesModal(false);
setIsDropdownVisible(false);
onRegenerateOutline();
},
},
queryClient,
);
const showUpdatePreferences =
aiCourse?.questionAndAnswers && aiCourse.questionAndAnswers.length > 0;
return (
<>
{showUpgradeModal && (
@@ -46,6 +92,19 @@ export function RegenerateOutline(props: RegenerateOutlineProps) {
/>
)}
{showUpdatePreferencesModal && (
<UpdatePreferences
onClose={() => setShowUpdatePreferencesModal(false)}
questionAndAnswers={aiCourse?.questionAndAnswers}
term={aiCourse?.keyword || ''}
format="course"
onUpdatePreferences={(questionAndAnswers) => {
updatePreferences(questionAndAnswers);
}}
isUpdating={isUpdating}
/>
)}
<div ref={ref} className="relative flex items-stretch">
<button
className={cn('rounded-md px-2.5 text-gray-400 hover:text-black', {
@@ -56,7 +115,28 @@ export function RegenerateOutline(props: RegenerateOutlineProps) {
<PenSquare className="text-current" size={16} strokeWidth={2.5} />
</button>
{isDropdownVisible && (
<div className="absolute top-full right-0 min-w-[170px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
<div className="absolute top-full right-0 min-w-[190px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
{showUpdatePreferences && (
<button
onClick={() => {
setIsDropdownVisible(false);
if (isForkable) {
onForkCourse();
return;
}
setShowUpdatePreferencesModal(true);
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
>
<Settings2Icon
size={16}
className="text-gray-400"
strokeWidth={2.5}
/>
Preferences
</button>
)}
<button
onClick={() => {
setIsDropdownVisible(false);

View File

@@ -95,9 +95,9 @@ export function UserCoursesList() {
</p>
{isUserAuthenticated && !isAnyLoading && courses.length > 0 && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3 gap-2">
{courses.map((course) => (
<AICourseCard key={course._id} course={course} />
<AICourseCard variant="column" key={course._id} course={course} />
))}
<Pagination

View File

@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { ExternalLink } from 'lucide-react';
import { AlertCircleIcon, ExternalLink } from 'lucide-react';
import { useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
import { generateGuide } from '../../helper/generate-ai-guide';
@@ -16,6 +16,9 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { AIGuideChat } from './AIGuideChat';
import { AIGuideContent } from './AIGuideContent';
import { GenerateAIGuide } from './GenerateAIGuide';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { billingDetailsOptions } from '../../queries/billing';
import { showLoginPopup } from '../../lib/popup';
type AIGuideProps = {
guideSlug?: string;
@@ -32,10 +35,23 @@ export function AIGuide(props: AIGuideProps) {
// only fetch the guide if the guideSlug is provided
// otherwise we are still generating the guide
const { data: aiGuide, isLoading: isLoadingBySlug } = useQuery(
getAiGuideOptions(guideSlug),
queryClient,
);
const {
data: aiGuide,
isLoading: isLoadingBySlug,
error: aiGuideError,
} = useQuery(getAiGuideOptions(guideSlug), queryClient);
const {
data: tokenUsage,
isLoading: isTokenUsageLoading,
refetch: refetchTokenUsage,
} = useQuery(getAiCourseLimitOptions(), queryClient);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const { data: aiGuideSuggestions, isLoading: isAiGuideSuggestionsLoading } =
useQuery(
@@ -57,6 +73,16 @@ export function AIGuide(props: AIGuideProps) {
}, [aiGuideSuggestions]);
const handleRegenerate = async (prompt?: string) => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (!isPaidUser && isLimitExceeded) {
setShowUpgradeModal(true);
return;
}
flushSync(() => {
setIsRegenerating(true);
setRegeneratedHtml(null);
@@ -78,7 +104,6 @@ export function AIGuide(props: AIGuideProps) {
await generateGuide({
slug: aiGuide?.slug || '',
term: aiGuide?.keyword || '',
depth: aiGuide?.depth || '',
prompt,
onStreamingChange: setIsRegenerating,
onHtmlChange: setRegeneratedHtml,
@@ -93,24 +118,44 @@ export function AIGuide(props: AIGuideProps) {
});
};
const isLoading =
isLoadingBySlug ||
isRegenerating ||
isTokenUsageLoading ||
isBillingDetailsLoading;
return (
<AITutorLayout
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden bg-white"
wrapperClassName="flex-row p-0 lg:p-0 relative overflow-hidden bg-white"
containerClassName="h-[calc(100vh-49px)] overflow-hidden relative"
>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
{!isLoading && aiGuideError && (
<div className="absolute inset-0 z-10 flex h-full flex-col items-center justify-center bg-white">
<div className="flex flex-col items-center justify-center gap-2">
<AlertCircleIcon className="size-10 text-gray-500" />
<p className="text-center">
{aiGuideError?.message || 'Something went wrong'}
</p>
</div>
</div>
)}
<div className="grow overflow-y-auto p-4 pt-0">
{guideSlug && (
{guideSlug && !aiGuideError && (
<AIGuideContent
html={regeneratedHtml || aiGuide?.html || ''}
onRegenerate={handleRegenerate}
isLoading={isLoadingBySlug || isRegenerating}
isLoading={isLoading}
guideSlug={guideSlug}
/>
)}
{!guideSlug && <GenerateAIGuide onGuideSlugChange={setGuideSlug} />}
{!guideSlug && !aiGuideError && (
<GenerateAIGuide onGuideSlugChange={setGuideSlug} />
)}
{aiGuide && !isRegenerating && (
<div className="mx-auto mt-12 mb-12 max-w-4xl">

View File

@@ -60,11 +60,8 @@ export function AIGuideChat(props: AIGuideChatProps) {
refetch: refetchTokenUsage,
} = useQuery(getAiCourseLimitOptions(), queryClient);
const {
data: userBillingDetails,
isLoading: isBillingDetailsLoading,
refetch: refetchBillingDetails,
} = useQuery(billingDetailsOptions(), queryClient);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
@@ -127,6 +124,10 @@ export function AIGuideChat(props: AIGuideChatProps) {
});
sendMessages(newMessages);
setInputValue('');
setTimeout(() => {
scrollToBottom('smooth');
}, 0);
},
[inputValue, isStreamingMessage, messages, sendMessages, setMessages],
);
@@ -332,6 +333,24 @@ export function AIGuideChat(props: AIGuideChatProps) {
</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>
)}
<input
ref={inputRef}
type="text"

View File

@@ -2,15 +2,17 @@ import './AIGuideContent.css';
import { AIGuideRegenerate } from './AIGuideRegenerate';
import { cn } from '../../lib/classname';
import { LoadingChip } from '../LoadingChip';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
type AIGuideContentProps = {
html: string;
onRegenerate?: (prompt?: string) => void;
isLoading?: boolean;
guideSlug?: string;
};
export function AIGuideContent(props: AIGuideContentProps) {
const { html, onRegenerate, isLoading } = props;
const { html, onRegenerate, isLoading, guideSlug } = props;
return (
<div
@@ -30,9 +32,12 @@ export function AIGuideContent(props: AIGuideContentProps) {
</div>
)}
{onRegenerate && !isLoading && (
{onRegenerate && !isLoading && guideSlug && (
<div className="absolute top-4 right-4">
<AIGuideRegenerate onRegenerate={onRegenerate} />
<AIGuideRegenerate
onRegenerate={onRegenerate}
guideSlug={guideSlug}
/>
</div>
)}
</div>

View File

@@ -1,25 +1,74 @@
import { PenSquare, RefreshCcw } from 'lucide-react';
import { PenSquare, RefreshCcw, SettingsIcon } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { ModifyCoursePrompt } from '../GenerateCourse/ModifyCoursePrompt';
import { UpdatePreferences } from './UpdatePreferences';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { useMutation, useQuery } from '@tanstack/react-query';
import { getAiGuideOptions } from '../../queries/ai-guide';
import { queryClient } from '../../stores/query-client';
import { httpPost } from '../../lib/query-http';
import { useAuth } from '../../hooks/use-auth';
import { showLoginPopup } from '../../lib/popup';
import { isLoggedIn } from '../../lib/jwt';
type AIGuideRegenerateProps = {
onRegenerate: (prompt?: string) => void;
guideSlug: string;
};
export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
const { onRegenerate } = props;
const { onRegenerate, guideSlug } = props;
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showPromptModal, setShowPromptModal] = useState(false);
const [showUpdatePreferencesModal, setShowUpdatePreferencesModal] =
useState(false);
const currentUser = useAuth();
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => setIsDropdownVisible(false));
const { data: aiGuide } = useQuery(getAiGuideOptions(guideSlug), queryClient);
const { mutate: updatePreferences, isPending: isUpdating } = useMutation(
{
mutationFn: (questionAndAnswers: QuestionAnswerChatMessage[]) => {
return httpPost(`/v1-update-guide-preferences/${guideSlug}`, {
questionAndAnswers,
});
},
onSuccess: (_, vars) => {
queryClient.setQueryData(
getAiGuideOptions(guideSlug).queryKey,
(old) => {
if (!old) {
return old;
}
return {
...old,
questionAndAnswers: vars,
};
},
);
setShowUpdatePreferencesModal(false);
setIsDropdownVisible(false);
onRegenerate();
},
},
queryClient,
);
const showUpdatePreferences =
aiGuide?.questionAndAnswers &&
aiGuide.questionAndAnswers.length > 0 &&
currentUser?.id === aiGuide.userId;
return (
<>
{showUpgradeModal && (
@@ -41,6 +90,19 @@ export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
/>
)}
{showUpdatePreferencesModal && (
<UpdatePreferences
onClose={() => setShowUpdatePreferencesModal(false)}
questionAndAnswers={aiGuide?.questionAndAnswers}
term={aiGuide?.keyword || ''}
format="guide"
onUpdatePreferences={(questionAndAnswers) => {
updatePreferences(questionAndAnswers);
}}
isUpdating={isUpdating}
/>
)}
<div ref={ref} className="relative flex items-stretch">
<button
className={cn('rounded-md px-2.5 text-gray-400 hover:text-black', {
@@ -51,9 +113,31 @@ export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
<PenSquare className="text-current" size={16} strokeWidth={2.5} />
</button>
{isDropdownVisible && (
<div className="absolute top-full right-0 min-w-[170px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
<div className="absolute top-full right-0 min-w-[190px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
{showUpdatePreferences && (
<button
onClick={() => {
setIsDropdownVisible(false);
setShowUpdatePreferencesModal(true);
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
>
<SettingsIcon
size={16}
className="text-gray-400"
strokeWidth={2.5}
/>
Update Preferences
</button>
)}
<button
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsDropdownVisible(false);
onRegenerate();
}}
@@ -68,6 +152,11 @@ export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
</button>
<button
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsDropdownVisible(false);
setShowPromptModal(true);
}}

View File

@@ -7,6 +7,8 @@ import { AIGuideContent } from './AIGuideContent';
import { queryClient } from '../../stores/query-client';
import { getAiGuideOptions } from '../../queries/ai-guide';
import { LoadingChip } from '../LoadingChip';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
type GenerateAIGuideProps = {
onGuideSlugChange?: (guideSlug: string) => void;
@@ -26,48 +28,32 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
useEffect(() => {
const params = getUrlParams();
const paramsTerm = params?.term;
const paramsDepth = params?.depth;
const paramsSrc = params?.src || 'search';
if (!paramsTerm || !paramsDepth) {
if (!paramsTerm) {
return;
}
let paramsGoal = '';
let paramsAbout = '';
let paramsCustomInstructions = '';
let questionAndAnswers: QuestionAnswerChatMessage[] = [];
const sessionId = params?.id;
if (sessionId) {
const fineTuneData = getCourseFineTuneData(sessionId);
if (fineTuneData) {
paramsGoal = fineTuneData.goal;
paramsAbout = fineTuneData.about;
paramsCustomInstructions = fineTuneData.customInstructions;
}
questionAndAnswers = getQuestionAnswerChatMessages(sessionId);
}
handleGenerateDocument({
term: paramsTerm,
depth: paramsDepth,
instructions: paramsCustomInstructions,
goal: paramsGoal,
about: paramsAbout,
src: paramsSrc,
questionAndAnswers,
});
}, []);
const handleGenerateDocument = async (options: {
term: string;
depth: string;
instructions?: string;
goal?: string;
about?: string;
isForce?: boolean;
prompt?: string;
src?: string;
questionAndAnswers?: QuestionAnswerChatMessage[];
}) => {
const { term, depth, isForce, prompt, instructions, goal, about, src } =
options;
const { term, isForce, prompt, src, questionAndAnswers } = options;
if (!isLoggedIn()) {
window.location.href = '/ai';
@@ -76,7 +62,6 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
await generateGuide({
term,
depth,
onDetailsChange: (details) => {
const { guideId, guideSlug, creatorId, title } = details;
@@ -86,7 +71,6 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
title,
html: htmlRef.current,
keyword: term,
depth,
content,
tokens: {
prompt: 0,
@@ -96,6 +80,7 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
relatedTopics: [],
deepDiveTopics: [],
questions: [],
questionAndAnswers,
viewCount: 0,
lastVisitedAt: new Date(),
createdAt: new Date(),
@@ -112,12 +97,10 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
},
onLoadingChange: setIsLoading,
onError: setError,
instructions,
goal,
about,
isForce,
prompt,
src,
questionAndAnswers,
onHtmlChange: (html) => {
htmlRef.current = html;
setHtml(html);

View File

@@ -0,0 +1,98 @@
import { useMemo, useState } from 'react';
import type { AllowedFormat } from '../ContentGenerator/ContentGenerator';
import {
QuestionAnswerChat,
type QuestionAnswerChatMessage,
} from '../ContentGenerator/QuestionAnswerChat';
import { Modal } from '../Modal';
type UpdatePreferencesProps = {
onClose: () => void;
term: string;
format: AllowedFormat;
questionAndAnswers?: QuestionAnswerChatMessage[];
onUpdatePreferences: (
questionAndAnswers: QuestionAnswerChatMessage[],
) => void;
isUpdating: boolean;
};
export function UpdatePreferences(props: UpdatePreferencesProps) {
const {
onClose,
questionAndAnswers: defaultQuestionAndAnswers,
term,
format,
onUpdatePreferences,
isUpdating,
} = props;
const [questionAnswerChatMessages, setQuestionAnswerChatMessages] = useState<
QuestionAnswerChatMessage[]
>(defaultQuestionAndAnswers || []);
const defaultQuestions = defaultQuestionAndAnswers
?.filter((message) => message.role === 'assistant')
.map((message) => ({
question: message.question,
possibleAnswers: message.possibleAnswers,
}));
const hasChangedQuestionAndAnswers = useMemo(() => {
return (
JSON.stringify(questionAnswerChatMessages) !==
JSON.stringify(defaultQuestionAndAnswers)
);
}, [questionAnswerChatMessages, defaultQuestionAndAnswers]);
console.log(questionAnswerChatMessages);
console.log(defaultQuestionAndAnswers);
const userAnswers = questionAnswerChatMessages.filter(
(message) => message.role === 'user',
);
const hasAnsweredAllQuestions =
userAnswers.length === defaultQuestions?.length;
return (
<Modal
onClose={onClose}
bodyClassName="p-4 flex flex-col gap-4"
wrapperClassName="max-w-xl h-auto"
overlayClassName="items-start md:items-center"
>
<div className="flex flex-col gap-1">
<h2 className="text-lg font-medium">Update Preferences</h2>
<p className="text-sm text-gray-500">
Update your preferences for better content
</p>
</div>
<QuestionAnswerChat
term={term}
format={format}
questionAnswerChatMessages={questionAnswerChatMessages}
setQuestionAnswerChatMessages={setQuestionAnswerChatMessages}
defaultQuestions={defaultQuestions}
onGenerateNow={() => {
onUpdatePreferences(questionAnswerChatMessages);
}}
className="-mx-2 h-[400px] border-none p-0"
type="update"
/>
{hasChangedQuestionAndAnswers && hasAnsweredAllQuestions && (
<button
className="rounded-lg bg-black px-4 py-2 text-white hover:opacity-80 disabled:opacity-50"
disabled={isUpdating || !hasChangedQuestionAndAnswers}
onClick={() => {
onUpdatePreferences(questionAnswerChatMessages);
}}
>
{isUpdating ? 'Updating...' : 'Apply preferences'}
</button>
)}
</Modal>
);
}

View File

@@ -1,84 +0,0 @@
import { Check, Clipboard } from 'lucide-react';
import { useRef } from 'react';
import { useAuth } from '../../hooks/use-auth';
import { useCopyText } from '../../hooks/use-copy-text';
import { useToast } from '../../hooks/use-toast';
import { cn } from '../../lib/classname';
import { Modal } from '../Modal';
type IncreaseRoadmapLimitProps = {
onClose: () => void;
};
export function IncreaseRoadmapLimit(props: IncreaseRoadmapLimitProps) {
const { onClose } = props;
const user = useAuth();
const toast = useToast();
const inputRef = useRef<HTMLInputElement>(null);
const { copyText, isCopied } = useCopyText();
const referralLink = new URL(
`/ai?rc=${user?.id}`,
import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh',
).toString();
const handleCopy = () => {
inputRef.current?.select();
copyText(referralLink);
toast.success('Copied to clipboard');
};
return (
<Modal
onClose={onClose}
overlayClassName={cn('overscroll-contain')}
wrapperClassName="max-w-lg mx-auto"
bodyClassName={cn('h-auto pt-px')}
>
<div className="p-4">
<h2 className="text-xl font-semibold text-gray-800">
Refer your Friends
</h2>
<p className="mt-2 text-sm text-gray-500">
Share the URL below with your friends. When they sign up with your
link, you will get extra roadmap generation credits.
</p>
<label className="mt-4 flex flex-col gap-2">
<input
ref={inputRef}
className="w-full rounded-md border bg-gray-100 p-2 px-2.5 text-gray-700 focus:outline-hidden"
value={referralLink}
readOnly={true}
onClick={handleCopy}
/>
<button
className={cn(
'flex h-10 items-center justify-center gap-1.5 rounded-md p-2 px-2.5 text-sm',
{
'bg-green-500 text-black transition-colors': isCopied,
'rounded-md bg-black text-white': !isCopied,
},
)}
onClick={handleCopy}
disabled={isCopied}
>
{isCopied ? (
<>
<Check className="h-4 w-4" />
Copied to Clipboard
</>
) : (
<>
<Clipboard className="h-4 w-4" />
Copy URL
</>
)}
</button>
</label>
</div>
</Modal>
);
}

View File

@@ -1,164 +0,0 @@
import { ChevronLeft } from 'lucide-react';
import { useAuth } from '../../hooks/use-auth';
type PayToBypassProps = {
onBack: () => void;
onClose: () => void;
};
export function PayToBypass(props: PayToBypassProps) {
const { onBack, onClose } = props;
const user = useAuth();
const userId = 'entry.1665642993';
const nameId = 'entry.527005328';
const emailId = 'entry.982906376';
const amountId = 'entry.1826002937';
const roadmapCountId = 'entry.1161404075';
const usageId = 'entry.535914744';
const feedbackId = 'entry.1024388959';
return (
<div className="p-4">
<button
onClick={onBack}
className="mb-5 flex items-center gap-1.5 text-sm leading-none opacity-40 transition-opacity hover:opacity-100 focus:outline-hidden"
>
<ChevronLeft size={16} />
Back to options
</button>
<h2 className="text-xl font-semibold text-gray-800">Pay to Bypass</h2>
<p className="mt-2 text-sm leading-normal text-gray-500">
Tell us more about how you will be using this.
</p>
<form
className="mt-4 flex flex-col gap-3"
action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSeec1oboTc9vCWHxmoKsC5NIbACpQEk7erp8wBKJMz-nzC7LQ/formResponse"
target="_blank"
>
<div className="sr-only" aria-hidden="true">
<label htmlFor={userId} className="sr-only">
User Id
</label>
<input
id={userId}
name={userId}
type="text"
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
value={user?.id}
readOnly
/>
</div>
<div className="sr-only" aria-hidden="true">
<label htmlFor={nameId} className="sr-only">
Name
</label>
<input
id={nameId}
name={nameId}
type="text"
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
value={user?.name}
readOnly
/>
</div>
<div className="sr-only" aria-hidden="true">
<label htmlFor={emailId} className="sr-only">
Email
</label>
<input
id={emailId}
name={emailId}
type="email"
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
value={user?.email}
readOnly
/>
</div>
<div>
<label
htmlFor={amountId}
className="mb-2 block text-sm font-semibold"
>
How much are you willing to pay for this? *
</label>
<input
id={amountId}
name={amountId}
type="text"
required
className="block w-full rounded-lg border p-3 py-2 shadow-xs outline-hidden placeholder:text-sm placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="How much are you willing to pay for this?"
/>
</div>
<div>
<label
htmlFor={roadmapCountId}
className="mb-2 block text-sm font-semibold"
>
How many roadmaps you will be generating (daily, or monthly)? *
</label>
<textarea
id={roadmapCountId}
name={roadmapCountId}
required
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="How many roadmaps you will be generating (daily, or monthly)?"
/>
</div>
<div>
<label htmlFor={usageId} className="mb-2 block text-sm font-semibold">
How will you be using this feature? *
</label>
<textarea
id={usageId}
name={usageId}
required
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="How will you be using this"
/>
</div>
<div>
<label
htmlFor={feedbackId}
className="mb-2 block text-sm font-semibold"
>
Do you have any feedback for us to improve this feature?
</label>
<textarea
id={feedbackId}
name={feedbackId}
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Do you have any feedback?"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
className="disbaled:opacity-60 w-full rounded-lg border border-gray-300 py-2 text-sm hover:bg-gray-100 disabled:cursor-not-allowed"
onClick={() => {
onClose();
}}
>
Cancel
</button>
<button
type="submit"
className="w-full rounded-lg bg-gray-900 py-2 text-sm text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
setTimeout(() => {
onClose();
}, 100);
}}
>
Submit
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,76 +0,0 @@
import { Check, Clipboard } from 'lucide-react';
import { useAuth } from '../../hooks/use-auth';
import { useCopyText } from '../../hooks/use-copy-text';
import { useToast } from '../../hooks/use-toast';
import { useRef } from 'react';
import { cn } from '../../lib/classname.ts';
type ReferYourFriendProps = {
onBack: () => void;
};
export function ReferYourFriend(props: ReferYourFriendProps) {
const { onBack } = props;
const user = useAuth();
const toast = useToast();
const inputRef = useRef<HTMLInputElement>(null);
const { copyText, isCopied } = useCopyText();
const referralLink = new URL(
`/ai?rc=${user?.id}`,
import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh',
).toString();
const handleCopy = () => {
inputRef.current?.select();
copyText(referralLink);
toast.success('Copied to clipboard');
};
return (
<div className="p-4">
<h2 className="text-xl font-semibold text-gray-800">
Refer your Friends
</h2>
<p className="mt-2 text-sm text-gray-500">
Share the URL below with your friends. When they sign up with your link,
you will get extra roadmap generation credits.
</p>
<label className="mt-4 flex flex-col gap-2">
<input
ref={inputRef}
className="w-full rounded-md border bg-gray-100 p-2 px-2.5 text-gray-700 focus:outline-hidden"
value={referralLink}
readOnly={true}
onClick={handleCopy}
/>
<button
className={cn(
'flex h-10 items-center justify-center gap-1.5 rounded-md p-2 px-2.5 text-sm',
{
'bg-green-500 text-black transition-colors': isCopied,
'bg-black text-white rounded-md': !isCopied,
},
)}
onClick={handleCopy}
disabled={isCopied}
>
{isCopied ? (
<>
<Check className="h-4 w-4" />
Copied to Clipboard
</>
) : (
<>
<Clipboard className="h-4 w-4" />
Copy URL
</>
)}
</button>
</label>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { BookOpen, FileTextIcon, type LucideIcon } from 'lucide-react';
import { BookOpen, FileTextIcon, MapIcon, type LucideIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
type LibraryTabsProps = {
activeTab: 'guides' | 'courses';
activeTab: 'guides' | 'courses' | 'roadmaps';
};
export function LibraryTabs(props: LibraryTabsProps) {
@@ -22,6 +22,12 @@ export function LibraryTabs(props: LibraryTabsProps) {
label="Guides"
href="/ai/guides"
/>
<LibraryTabButton
isActive={activeTab === 'roadmaps'}
icon={MapIcon}
label="Roadmaps"
href="/ai/roadmaps"
/>
</div>
);
}

View File

@@ -1,11 +1,11 @@
---
import { Fragment } from 'react';
import { AccountStreak } from '../AccountStreak/AccountStreak';
import Icon from '../AstroIcon.astro';
import { NavigationDropdown } from '../NavigationDropdown';
import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu';
import { RoadmapDropdownMenu } from '../TopNavDropdowns/RoadmapDropdownMenu';
import { AIDropdownMenu } from '../TopNavDropdowns/AIDropdownMenu';
import { AccountDropdown } from './AccountDropdown';
import { CourseAnnouncement } from '../SQLCourse/CourseAnnouncement';
import { Fragment } from 'react';
---
<div class='bg-slate-900 py-5 text-white sm:py-8'>
@@ -33,21 +33,7 @@ import { Fragment } from 'react';
Start Here
</a>
<RoadmapDropdownMenu client:load />
<a
href='/ai'
class='group relative mr-3 text-blue-300 hover:text-white'
>
AI Tutor
<span class='absolute -right-[11px] top-0'>
<span class='relative flex h-2 w-2'>
<span
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
></span>
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
></span>
</span>
</span>
</a>
<AIDropdownMenu client:load />
<a
href='/teams'
class='group relative hidden text-gray-400 hover:text-white xl:block'
@@ -86,12 +72,12 @@ import { Fragment } from 'react';
<!-- Mobile Navigation Items -->
<div
class='fixed bottom-0 left-0 right-0 top-0 z-40 flex hidden items-center bg-slate-900'
class='fixed top-0 right-0 bottom-0 left-0 z-40 flex hidden items-center bg-slate-900'
data-mobile-nav
>
<button
data-close-mobile-nav
class='absolute right-6 top-6 block cursor-pointer text-gray-400 hover:text-gray-50'
class='absolute top-6 right-6 block cursor-pointer text-gray-400 hover:text-gray-50'
aria-label='Close Menu'
>
<Icon icon='close' />

View File

@@ -0,0 +1,101 @@
import { useRef } from 'react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '../lib/classname.ts';
import { useOutsideClick } from '../hooks/use-outside-click.ts';
export interface DropdownLink {
link: string;
label: string;
description: string;
Icon: LucideIcon;
isExternal?: boolean;
isHighlighted?: boolean;
isNew?: boolean;
}
interface NavigationDropdownMenuProps {
links: DropdownLink[];
trigger: React.ReactNode;
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
}
export function NavigationDropdownMenu(props: NavigationDropdownMenuProps) {
const { links, trigger, isOpen, onOpen, onClose } = props;
const dropdownRef = useRef<HTMLDivElement>(null);
useOutsideClick(dropdownRef, onClose);
return (
<div className="relative flex items-center" ref={dropdownRef}>
<button
className={cn('text-gray-400 hover:text-white', {
'text-white': isOpen,
})}
onClick={onOpen}
onMouseOver={onOpen}
aria-label="Open Navigation Dropdown"
aria-expanded={isOpen}
>
{trigger}
</button>
<div
className={cn(
'pointer-events-none invisible absolute top-full left-0 z-90 mt-2 w-48 min-w-[320px] -translate-y-1 rounded-lg bg-slate-800 py-2 opacity-0 shadow-xl transition-all duration-100',
{
'pointer-events-auto visible translate-y-2.5 opacity-100': isOpen,
},
)}
role="menu"
>
{links.map((link) => (
<a
href={link.link}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
key={link.link}
className={cn(
'group flex items-center gap-3 px-4 py-2.5 text-gray-400 transition-colors hover:bg-slate-700',
{
'mx-2 mb-1 rounded-md border border-slate-600 bg-slate-700 pl-2.5 text-gray-200 hover:bg-slate-600':
link.isHighlighted,
},
)}
role="menuitem"
>
<span
className={cn(
'flex h-[40px] w-[40px] items-center justify-center rounded-full bg-slate-600 transition-colors group-hover:bg-slate-500 group-hover:text-slate-100',
{
'bg-slate-500 text-slate-100': link.isHighlighted,
},
)}
>
<link.Icon className="inline-block h-5 w-5" />
</span>
<span className="flex flex-col">
<span
className={cn(
'font-medium text-slate-300 transition-colors group-hover:text-slate-100',
{
'text-white': link.isHighlighted,
},
)}
>
{link.label}{' '}
{link.isNew && (
<span className="relative -top-0.5 rounded-full bg-yellow-400 px-1.5 py-0.5 text-[10px] font-bold tracking-wider text-black uppercase">
New
</span>
)}
</span>
<span className="text-sm">{link.description}</span>
</span>
</a>
))}
</div>
</div>
);
}

View File

@@ -1,116 +0,0 @@
import { ChevronDown, Globe, Sparkles, Map } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
import {
navigationDropdownOpen,
roadmapsDropdownOpen,
} from '../../stores/page.ts';
import { useStore } from '@nanostores/react';
const links = [
{
link: '/roadmaps',
label: 'Official Roadmaps',
description: 'Made by subject matter experts',
Icon: Map,
isHighlighted: true,
},
{
link: '/ai-roadmaps',
label: 'AI Roadmaps',
description: 'Generate roadmaps with AI',
Icon: Sparkles,
isHighlighted: false,
},
{
link: '/community',
label: 'Community Roadmaps',
description: 'Made by community members',
Icon: Globe,
isHighlighted: false,
},
];
export function RoadmapDropdownMenu() {
const dropdownRef = useRef<HTMLDivElement>(null);
const $roadmapsDropdownOpen = useStore(roadmapsDropdownOpen);
const $navigationDropdownOpen = useStore(navigationDropdownOpen);
useOutsideClick(dropdownRef, () => {
roadmapsDropdownOpen.set(false);
});
useEffect(() => {
if ($navigationDropdownOpen) {
roadmapsDropdownOpen.set(false);
}
}, [$navigationDropdownOpen]);
return (
<div className="relative flex items-center" ref={dropdownRef}>
<button
className={cn('text-gray-400 hover:text-white', {
'text-white': $roadmapsDropdownOpen,
})}
onClick={() => roadmapsDropdownOpen.set(true)}
onMouseOver={() => roadmapsDropdownOpen.set(true)}
aria-label="Open Navigation Dropdown"
aria-expanded={$roadmapsDropdownOpen}
>
Roadmaps{' '}
<ChevronDown className="inline-block h-3 w-3" strokeWidth={4} />
</button>
<div
className={cn(
'pointer-events-none invisible absolute left-0 top-full z-90 mt-2 w-48 min-w-[320px] -translate-y-1 rounded-lg bg-slate-800 py-2 opacity-0 shadow-2xl transition-all duration-100',
{
'pointer-events-auto visible translate-y-2.5 opacity-100':
$roadmapsDropdownOpen,
},
)}
role="menu"
>
{links.map((link) => (
<a
href={link.link}
key={link.link}
className={cn(
'group flex items-center gap-3 px-4 py-2.5 text-gray-400 transition-colors hover:bg-slate-700',
{
'mx-2 mb-1 rounded-md border border-slate-600 bg-slate-700 text-gray-200 hover:bg-slate-600':
link.isHighlighted,
},
)}
role="menuitem"
>
<span
className={cn(
'flex h-[40px] w-[40px] items-center justify-center rounded-full bg-slate-600 transition-colors group-hover:bg-slate-500 group-hover:text-slate-100',
{
'bg-slate-500 text-slate-100': link.isHighlighted,
},
)}
>
<link.Icon className="inline-block h-5 w-5" />
</span>
<span className="flex flex-col">
<span
className={cn(
'font-medium text-slate-300 transition-colors group-hover:text-slate-100',
{
'text-white': link.isHighlighted,
},
)}
>
{link.label}
</span>
<span className="text-sm">{link.description}</span>
</span>
</a>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { ChevronDown, MessageCircle, Plus } from 'lucide-react';
import { useEffect } from 'react';
import { NavigationDropdownMenu } from '../NavigationDropdownMenu.tsx';
import {
navigationDropdownOpen,
aiDropdownOpen,
roadmapsDropdownOpen,
} from '../../stores/page.ts';
import { useStore } from '@nanostores/react';
const links = [
{
link: '/ai',
label: 'Create with AI',
description: 'Learn something new with AI',
Icon: Plus,
},
{
link: '/ai/chat',
label: 'Ask AI Tutor',
description: 'Career, resume guidance, and more',
Icon: MessageCircle,
},
];
export function AIDropdownMenu() {
const isOpen = useStore(aiDropdownOpen);
const isNavOpen = useStore(navigationDropdownOpen);
useEffect(() => {
if (isNavOpen) {
aiDropdownOpen.set(false);
}
}, [isNavOpen]);
return (
<NavigationDropdownMenu
links={links}
trigger={
<span className="group relative mr-3 flex items-center gap-1.5 text-blue-300 hover:text-white">
AI Tutor
<ChevronDown className="inline-block h-3 w-3" strokeWidth={4} />
<span className="absolute top-0 -right-[11px]">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-sky-500"></span>
</span>
</span>
</span>
}
isOpen={isOpen}
onOpen={() => {
navigationDropdownOpen.set(false);
roadmapsDropdownOpen.set(false);
aiDropdownOpen.set(true);
}}
onClose={() => aiDropdownOpen.set(false)}
/>
);
}

View File

@@ -0,0 +1,63 @@
import { ChevronDown, Globe, Map, Sparkles } from 'lucide-react';
import { useEffect } from 'react';
import { NavigationDropdownMenu } from '../NavigationDropdownMenu.tsx';
import {
aiDropdownOpen,
navigationDropdownOpen,
roadmapsDropdownOpen,
} from '../../stores/page.ts';
import { useStore } from '@nanostores/react';
const links = [
{
link: '/roadmaps',
label: 'Official Roadmaps',
description: 'Made by subject matter experts',
Icon: Map,
isHighlighted: true,
},
{
link: '/ai?format=roadmap',
label: 'AI Roadmaps',
description: 'Generate roadmaps with AI',
Icon: Sparkles,
isHighlighted: false,
},
{
link: '/community',
label: 'Community Roadmaps',
description: 'Made by community members',
Icon: Globe,
isHighlighted: false,
},
];
export function RoadmapDropdownMenu() {
const isOpen = useStore(roadmapsDropdownOpen);
const isNavOpen = useStore(navigationDropdownOpen);
useEffect(() => {
if (isNavOpen) {
roadmapsDropdownOpen.set(false);
}
}, [isNavOpen]);
return (
<NavigationDropdownMenu
links={links}
trigger={
<span>
Roadmaps{' '}
<ChevronDown className="inline-block h-3 w-3" strokeWidth={4} />
</span>
}
isOpen={isOpen}
onOpen={() => {
navigationDropdownOpen.set(false);
aiDropdownOpen.set(false);
roadmapsDropdownOpen.set(true);
}}
onClose={() => roadmapsDropdownOpen.set(false)}
/>
);
}

View File

@@ -5,16 +5,13 @@ import {
} from '../lib/ai';
import { queryClient } from '../stores/query-client';
import { getAiCourseLimitOptions } from '../queries/ai-course';
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
type GenerateCourseOptions = {
term: string;
difficulty: string;
slug?: string;
isForce?: boolean;
prompt?: string;
instructions?: string;
goal?: string;
about?: string;
onCourseIdChange?: (courseId: string) => void;
onCourseSlugChange?: (courseSlug: string) => void;
onCourseChange?: (course: AiCourse, rawData: string) => void;
@@ -22,13 +19,13 @@ type GenerateCourseOptions = {
onCreatorIdChange?: (creatorId: string) => void;
onError?: (error: string) => void;
src?: string;
questionAndAnswers?: QuestionAnswerChatMessage[];
};
export async function generateCourse(options: GenerateCourseOptions) {
const {
term,
slug,
difficulty,
onCourseIdChange,
onCourseSlugChange,
onCourseChange,
@@ -37,10 +34,8 @@ export async function generateCourse(options: GenerateCourseOptions) {
onCreatorIdChange,
isForce = false,
prompt,
instructions,
goal,
about,
src = 'search',
questionAndAnswers,
} = options;
onLoadingChange?.(true);
@@ -48,7 +43,6 @@ export async function generateCourse(options: GenerateCourseOptions) {
{
title: '',
modules: [],
difficulty: '',
done: [],
},
'',
@@ -83,12 +77,9 @@ export async function generateCourse(options: GenerateCourseOptions) {
},
body: JSON.stringify({
keyword: term,
difficulty,
isForce,
customPrompt: prompt,
instructions,
goal,
about,
questionAndAnswers,
src,
}),
credentials: 'include',
@@ -136,7 +127,6 @@ export async function generateCourse(options: GenerateCourseOptions) {
courseId: extractedCourseId,
courseSlug: extractedCourseSlug,
term,
difficulty,
},
'',
`${origin}/ai/${extractedCourseSlug}`,
@@ -155,13 +145,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
try {
const aiCourse = generateAiCourseStructure(result);
onCourseChange?.(
{
...aiCourse,
difficulty: difficulty || '',
},
result,
);
onCourseChange?.(aiCourse, result);
} catch (e) {
console.error('Error parsing streamed course content:', e);
}

View File

@@ -2,6 +2,7 @@ import { queryClient } from '../stores/query-client';
import { getAiCourseLimitOptions } from '../queries/ai-course';
import { readChatStream } from '../lib/chat';
import { markdownToHtmlWithHighlighting } from '../lib/markdown';
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
type GuideDetails = {
guideId: string;
@@ -12,13 +13,9 @@ type GuideDetails = {
type GenerateGuideOptions = {
term: string;
depth: string;
slug?: string;
isForce?: boolean;
prompt?: string;
instructions?: string;
goal?: string;
about?: string;
onGuideSlugChange?: (guideSlug: string) => void;
onGuideChange?: (guide: string) => void;
onLoadingChange?: (isLoading: boolean) => void;
@@ -28,26 +25,24 @@ type GenerateGuideOptions = {
onStreamingChange?: (isStreaming: boolean) => void;
onDetailsChange?: (details: GuideDetails) => void;
onFinish?: () => void;
questionAndAnswers?: QuestionAnswerChatMessage[];
};
export async function generateGuide(options: GenerateGuideOptions) {
const {
term,
slug,
depth,
onGuideChange,
onLoadingChange,
onError,
isForce = false,
prompt,
instructions,
goal,
about,
src = 'search',
onHtmlChange,
onStreamingChange,
onDetailsChange,
onFinish,
questionAndAnswers,
} = options;
onLoadingChange?.(true);
@@ -80,12 +75,9 @@ export async function generateGuide(options: GenerateGuideOptions) {
},
body: JSON.stringify({
keyword: term,
depth,
isForce,
customPrompt: prompt,
instructions,
goal,
about,
questionAndAnswers,
src,
}),
credentials: 'include',

29
src/lib/ai-questions.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
export function storeQuestionAnswerChatMessages(
messages: QuestionAnswerChatMessage[],
) {
const sessionId = Date.now().toString();
localStorage.setItem(sessionId, JSON.stringify(messages));
localStorage.setItem('lastMessagesSessionId', sessionId);
return sessionId;
}
export function getQuestionAnswerChatMessages(sessionId: string) {
const messages = localStorage.getItem(sessionId);
if (!messages) {
return [];
}
return JSON.parse(messages);
}
export function getLastMessagesSessionId() {
return localStorage.getItem('lastMessagesSessionId');
}
export function clearQuestionAnswerChatMessages() {
localStorage.removeItem('lastMessagesSessionId');
}

View File

@@ -12,13 +12,10 @@ type Module = {
export type AiCourse = {
title: string;
modules: Module[];
difficulty: string;
done: string[];
};
export function generateAiCourseStructure(
data: string,
): Omit<AiCourse, 'difficulty'> {
export function generateAiCourseStructure(data: string): AiCourse {
const lines = data.split('\n');
let title = '';
const modules: Module[] = [];

View File

@@ -1,8 +1,6 @@
---
import { aiRoadmapApi } from '../../api/ai-roadmap';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { GenerateRoadmap } from '../../components/GenerateRoadmap/GenerateRoadmap';
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
import { AIRoadmap } from '../../components/AIRoadmap/AIRoadmap';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
export const prerender = false;
@@ -11,26 +9,14 @@ interface Params extends Record<string, string | undefined> {
}
const { aiRoadmapSlug } = Astro.params as Params;
if (!aiRoadmapSlug) {
return Astro.redirect('/404');
}
const aiRoadmapClient = aiRoadmapApi(Astro as any);
const { response: roadmap, error } =
await aiRoadmapClient.getAIRoadmapBySlug(aiRoadmapSlug);
let errorMessage = '';
if (error || !roadmap) {
errorMessage = error?.message || 'Error loading AI Roadmap';
}
const title = roadmap?.title || 'Roadmap AI';
---
<BaseLayout title={title} noIndex={true}>
<GenerateRoadmap
roadmapId={roadmap?.id}
isAuthenticatedUser={roadmap?.isAuthenticatedUser}
client:load
/>
<CheckSubscriptionVerification client:load />
</BaseLayout>
<SkeletonLayout
title='AI Tutor'
briefTitle='AI Tutor'
description='AI Tutor'
keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl={`/ai-roadmaps/${aiRoadmapSlug}`}
>
<AIRoadmap client:load roadmapSlug={aiRoadmapSlug} />
</SkeletonLayout>

View File

@@ -0,0 +1,15 @@
---
import { AIRoadmap } from '../../../components/AIRoadmap/AIRoadmap';
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
---
<SkeletonLayout
title='AI Tutor'
briefTitle='AI Tutor'
description='AI Tutor'
keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl='/ai/guide'
noIndex={true}
>
<AIRoadmap client:load />
</SkeletonLayout>

View File

@@ -0,0 +1,17 @@
---
import { UserRoadmapsList } from '../../components/AIRoadmap/UserRoadmapsList';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { AILibraryLayout } from '../../components/AIGuide/AILibraryLayout';
const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
---
<SkeletonLayout
title='Roadmap AI'
noIndex={true}
ogImageUrl={ogImage}
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
>
<AILibraryLayout activeTab='roadmaps' client:load>
<UserRoadmapsList client:load />
</AILibraryLayout>
</SkeletonLayout>

View File

@@ -1,6 +1,7 @@
import { httpGet } from '../lib/query-http';
import { isLoggedIn } from '../lib/jwt';
import { queryOptions } from '@tanstack/react-query';
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
export interface AICourseProgressDocument {
_id: string;
@@ -30,6 +31,7 @@ export interface AICourseDocument {
difficulty: string;
modules: AICourseModule[];
viewCount: number;
questionAndAnswers?: QuestionAnswerChatMessage[];
createdAt: Date;
updatedAt: Date;
}
@@ -45,6 +47,7 @@ export function getAiCourseOptions(params: GetAICourseParams) {
);
},
enabled: !!params.aiCourseSlug,
refetchOnMount: false,
};
}

View File

@@ -5,6 +5,7 @@ import {
markdownToHtml,
markdownToHtmlWithHighlighting,
} from '../lib/markdown';
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
export interface AIGuideDocument {
_id: string;
@@ -12,7 +13,6 @@ export interface AIGuideDocument {
title: string;
slug?: string;
keyword: string;
depth: string;
content: string;
tokens: {
prompt: number;
@@ -24,6 +24,8 @@ export interface AIGuideDocument {
deepDiveTopics: string[];
questions: string[];
questionAndAnswers?: QuestionAnswerChatMessage[];
viewCount: number;
lastVisitedAt: Date;
createdAt: Date;
@@ -64,6 +66,7 @@ export function aiGuideSuggestionsOptions(guideSlug?: string) {
);
},
enabled: !!guideSlug && !!isLoggedIn(),
refetchOnMount: false,
});
}

213
src/queries/ai-roadmap.ts Normal file
View File

@@ -0,0 +1,213 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
import { generateAICourseRoadmapStructure } from '../lib/ai';
import { generateAIRoadmapFromText, renderFlowJSON } from '@roadmapsh/editor';
export interface AIRoadmapDocument {
_id: string;
userId?: string;
userIp?: string;
title: string;
slug?: string;
term: string;
data: string;
viewCount: number;
lastVisitedAt: Date;
keyType?: 'system' | 'user';
questionAndAnswers?: QuestionAnswerChatMessage[];
createdAt: Date;
updatedAt: Date;
}
export type AIRoadmapResponse = AIRoadmapDocument & {
svgHtml?: string;
};
export function aiRoadmapOptions(roadmapSlug?: string) {
return queryOptions<AIRoadmapResponse>({
queryKey: ['ai-roadmap', roadmapSlug],
queryFn: async () => {
const res = await httpGet<AIRoadmapResponse>(
`/v1-get-ai-roadmap/${roadmapSlug}`,
);
const result = generateAICourseRoadmapStructure(res.data);
const { nodes, edges } = generateAIRoadmapFromText(result);
const svg = await renderFlowJSON({ nodes, edges });
const svgHtml = svg.outerHTML;
return {
...res,
svgHtml,
};
},
enabled: !!roadmapSlug,
});
}
import { queryClient } from '../stores/query-client';
import { getAiCourseLimitOptions } from '../queries/ai-course';
import { readChatStream } from '../lib/chat';
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
import type { AIGuideDocument } from './ai-guide';
import { isLoggedIn } from '../lib/jwt';
type RoadmapDetails = {
roadmapId: string;
roadmapSlug: string;
userId: string;
title: string;
};
type GenerateAIRoadmapOptions = {
term: string;
isForce?: boolean;
prompt?: string;
questionAndAnswers?: QuestionAnswerChatMessage[];
roadmapSlug?: string;
onRoadmapSvgChange?: (svg: SVGElement) => void;
onDetailsChange?: (details: RoadmapDetails) => void;
onLoadingChange?: (isLoading: boolean) => void;
onStreamingChange?: (isStreaming: boolean) => void;
onError?: (error: string) => void;
onFinish?: () => void;
};
export async function generateAIRoadmap(options: GenerateAIRoadmapOptions) {
const {
term,
roadmapSlug,
onLoadingChange,
onError,
isForce = false,
prompt,
onDetailsChange,
onFinish,
questionAndAnswers,
onRoadmapSvgChange,
onStreamingChange,
} = options;
onLoadingChange?.(true);
onStreamingChange?.(false);
try {
let response = null;
if (roadmapSlug && isForce) {
response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-regenerate-ai-roadmap/${roadmapSlug}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
prompt,
}),
},
);
} else {
response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
term,
isForce,
customPrompt: prompt,
questionAndAnswers,
}),
credentials: 'include',
},
);
}
if (!response.ok) {
const data = await response.json();
console.error(
'Error generating course:',
data?.message || 'Something went wrong',
);
onLoadingChange?.(false);
onError?.(data?.message || 'Something went wrong');
return;
}
const stream = response.body;
if (!stream) {
console.error('Failed to get stream from response');
onError?.('Something went wrong');
onLoadingChange?.(false);
return;
}
onLoadingChange?.(false);
onStreamingChange?.(true);
await readChatStream(stream, {
onMessage: async (message) => {
const result = generateAICourseRoadmapStructure(message);
const { nodes, edges } = generateAIRoadmapFromText(result);
const svg = await renderFlowJSON({ nodes, edges });
onRoadmapSvgChange?.(svg);
},
onMessageEnd: async () => {
queryClient.invalidateQueries(getAiCourseLimitOptions());
onStreamingChange?.(false);
},
onDetails: async (details) => {
if (!details?.roadmapId || !details?.roadmapSlug) {
throw new Error('Invalid details');
}
onDetailsChange?.(details);
},
});
onFinish?.();
} catch (error: any) {
onError?.(error?.message || 'Something went wrong');
console.error('Error in course generation:', error);
onLoadingChange?.(false);
onStreamingChange?.(false);
}
}
export type ListUserAiRoadmapsQuery = {
perPage?: string;
currPage?: string;
query?: string;
};
export type ListUserAiRoadmapsResponse = {
data: Omit<AIRoadmapDocument, 'data' | 'questionAndAnswers'>[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function listUserAiRoadmapsOptions(
params: ListUserAiRoadmapsQuery = {
perPage: '21',
currPage: '1',
query: '',
},
) {
return queryOptions({
queryKey: ['user-ai-roadmaps', params],
queryFn: () => {
return httpGet<ListUserAiRoadmapsResponse>(
`/v1-list-user-ai-roadmaps`,
params,
);
},
enabled: !!isLoggedIn(),
});
}

View File

@@ -0,0 +1,37 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
type AIQuestionSuggestionsQuery = {
term: string;
format: string;
};
export type AIQuestionSuggestionsResponse = {
questions: {
question: string;
possibleAnswers: string[];
}[];
};
export function aiQuestionSuggestionsOptions(
query: AIQuestionSuggestionsQuery,
defaultQuestions?: AIQuestionSuggestionsResponse['questions'],
) {
return queryOptions({
queryKey: ['ai-question-suggestions', query],
queryFn: () => {
if (defaultQuestions) {
return {
questions: defaultQuestions,
};
}
return httpGet<AIQuestionSuggestionsResponse>(
`/v1-ai-question-suggestions`,
query,
);
},
enabled: !!query.term && !!query.format,
refetchOnMount: false,
});
}

View File

@@ -6,4 +6,6 @@ export const sponsorHidden = atom(false);
export const roadmapsDropdownOpen = atom(false);
export const navigationDropdownOpen = atom(false);
export const isOnboardingStripHidden = atom(false);
export const isOnboardingStripHidden = atom(false);
export const aiDropdownOpen = atom(false);

View File

@@ -5,6 +5,7 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnMount: false,
enabled: !import.meta.env.SSR,
},
},