feat: ai document (#8793)
* Refactor AI course * Add AI course generation functionality * Add basic error handling * AI Document content * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * feat: regenerate guide * wip * wip * wip * wip * wip * fix: form ui * feat: update guide ui * refactor: update the course card * fix: term and redirects * Guide page UI improvements * Loading chip refactor * AI guide changes * Improve UI for ai guide content * Add AI guide * AI Guide chat * fix: stop streaming * fix: chat responsiveness * UI improvements for ai library * Guide listing UI update * User guides listing * Library guides listing UI * Library guides listing UI * Featured courses listing UI update * Staff picks UI changes * Community page UI design * Explore courses listing functionality * Improve UI for explore page * Implement guides functionality --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
1 parent
469f4ca530
commit
89932bc18d
56 files changed
+3959
-280
No files matched your search
@@ -0,0 +1,231 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { generateGuide } from '../../helper/generate-ai-guide';
|
||||
import { shuffle } from '../../helper/shuffle';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
aiGuideSuggestionsOptions,
|
||||
getAiGuideOptions,
|
||||
} from '../../queries/ai-guide';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AITutorLayout } from '../AITutor/AITutorLayout';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { AIGuideChat } from './AIGuideChat';
|
||||
import { AIGuideContent } from './AIGuideContent';
|
||||
import { GenerateAIGuide } from './GenerateAIGuide';
|
||||
|
||||
type AIGuideProps = {
|
||||
guideSlug?: string;
|
||||
};
|
||||
|
||||
export function AIGuide(props: AIGuideProps) {
|
||||
const { guideSlug: defaultGuideSlug } = props;
|
||||
const [guideSlug, setGuideSlug] = useState(defaultGuideSlug);
|
||||
|
||||
const toast = useToast();
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [regeneratedHtml, setRegeneratedHtml] = useState<string | null>(null);
|
||||
|
||||
// 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: aiGuideSuggestions, isLoading: isAiGuideSuggestionsLoading } =
|
||||
useQuery(
|
||||
{
|
||||
...aiGuideSuggestionsOptions(guideSlug),
|
||||
enabled: !!guideSlug && !!isLoggedIn(),
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const randomQuestions = useMemo(() => {
|
||||
return shuffle(aiGuideSuggestions?.questions || []).slice(0, 4);
|
||||
}, [aiGuideSuggestions]);
|
||||
const relatedTopics = useMemo(() => {
|
||||
return shuffle(aiGuideSuggestions?.relatedTopics || []).slice(0, 2);
|
||||
}, [aiGuideSuggestions]);
|
||||
const deepDiveTopics = useMemo(() => {
|
||||
return shuffle(aiGuideSuggestions?.deepDiveTopics || []).slice(0, 2);
|
||||
}, [aiGuideSuggestions]);
|
||||
|
||||
const handleRegenerate = async (prompt?: string) => {
|
||||
flushSync(() => {
|
||||
setIsRegenerating(true);
|
||||
setRegeneratedHtml(null);
|
||||
});
|
||||
|
||||
queryClient.cancelQueries(getAiGuideOptions(guideSlug));
|
||||
queryClient.setQueryData(getAiGuideOptions(guideSlug).queryKey, (old) => {
|
||||
if (!old) {
|
||||
return old;
|
||||
}
|
||||
|
||||
return {
|
||||
...old,
|
||||
content: '',
|
||||
html: '',
|
||||
};
|
||||
});
|
||||
|
||||
await generateGuide({
|
||||
slug: aiGuide?.slug || '',
|
||||
term: aiGuide?.keyword || '',
|
||||
depth: aiGuide?.depth || '',
|
||||
prompt,
|
||||
onStreamingChange: setIsRegenerating,
|
||||
onHtmlChange: setRegeneratedHtml,
|
||||
onFinish: () => {
|
||||
setIsRegenerating(false);
|
||||
queryClient.invalidateQueries(getAiGuideOptions(guideSlug));
|
||||
},
|
||||
isForce: true,
|
||||
onError: (error) => {
|
||||
toast.error(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AITutorLayout
|
||||
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden bg-white"
|
||||
containerClassName="h-[calc(100vh-49px)] overflow-hidden relative"
|
||||
>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
||||
)}
|
||||
|
||||
<div className="grow overflow-y-auto p-4 pt-0">
|
||||
{guideSlug && (
|
||||
<AIGuideContent
|
||||
html={regeneratedHtml || aiGuide?.html || ''}
|
||||
onRegenerate={handleRegenerate}
|
||||
isLoading={isLoadingBySlug || isRegenerating}
|
||||
/>
|
||||
)}
|
||||
{!guideSlug && <GenerateAIGuide onGuideSlugChange={setGuideSlug} />}
|
||||
|
||||
{aiGuide && !isRegenerating && (
|
||||
<div className="mx-auto mt-12 mb-12 max-w-4xl">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<ListSuggestions
|
||||
title="Related Topics"
|
||||
suggestions={relatedTopics}
|
||||
depth="essentials"
|
||||
isLoading={isAiGuideSuggestionsLoading}
|
||||
currentGuideTitle={aiGuide.title}
|
||||
/>
|
||||
|
||||
<ListSuggestions
|
||||
title="Dive Deeper"
|
||||
suggestions={deepDiveTopics}
|
||||
depth="detailed"
|
||||
isLoading={isAiGuideSuggestionsLoading}
|
||||
currentGuideTitle={aiGuide.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AIGuideChat
|
||||
guideSlug={guideSlug}
|
||||
isGuideLoading={!aiGuide}
|
||||
onUpgrade={() => setShowUpgradeModal(true)}
|
||||
randomQuestions={randomQuestions}
|
||||
isQuestionsLoading={isAiGuideSuggestionsLoading}
|
||||
/>
|
||||
</AITutorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
type ListSuggestionsProps = {
|
||||
currentGuideTitle?: string;
|
||||
title: string;
|
||||
suggestions: string[];
|
||||
depth: string;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function ListSuggestions(props: ListSuggestionsProps) {
|
||||
const { title, suggestions, depth, isLoading, currentGuideTitle } = props;
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-gray-300 bg-linear-to-br from-gray-100 to-gray-50 shadow-xs transition-all duration-200">
|
||||
<div className="border-b border-gray-200 bg-white px-5 py-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{depth === 'essentials'
|
||||
? 'Explore related concepts to expand your knowledge'
|
||||
: 'Take a deeper dive into specific areas'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 w-full animate-pulse rounded-lg bg-white"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && suggestions?.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{suggestions.map((topic) => {
|
||||
const topicTerm =
|
||||
depth === 'essentials'
|
||||
? `I have covered the basics of ${currentGuideTitle} and want to learn more about ${topic}`
|
||||
: `I have covered the basics of ${currentGuideTitle} and want to dive deeper into ${topic}`;
|
||||
const url = `/ai/guide?term=${encodeURIComponent(topicTerm)}&depth=${depth}&id=&format=guide`;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={topic}
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="group/item flex items-center justify-between rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 transition-all duration-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
<span className="flex-1 truncate group-hover/item:text-gray-900">
|
||||
{topic}
|
||||
</span>
|
||||
<ExternalLink className="ml-2 size-4 text-gray-400 group-hover/item:text-gray-600" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && suggestions?.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="mb-3 rounded-full bg-gray-100 p-3">
|
||||
<svg
|
||||
className="h-6 w-6 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">No suggestions available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} 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';
|
||||
|
||||
type AIGuideChatProps = {
|
||||
guideSlug?: string;
|
||||
isGuideLoading?: boolean;
|
||||
onUpgrade?: () => void;
|
||||
isQuestionsLoading?: boolean;
|
||||
randomQuestions?: string[];
|
||||
};
|
||||
|
||||
export function AIGuideChat(props: AIGuideChatProps) {
|
||||
const {
|
||||
guideSlug,
|
||||
isGuideLoading,
|
||||
onUpgrade,
|
||||
randomQuestions,
|
||||
isQuestionsLoading,
|
||||
} = props;
|
||||
|
||||
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,
|
||||
refetch: refetchBillingDetails,
|
||||
} = 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-guide-chat`,
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
data: {
|
||||
guideSlug,
|
||||
},
|
||||
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('');
|
||||
},
|
||||
[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 =
|
||||
isGuideLoading || 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]);
|
||||
|
||||
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 Guide
|
||||
</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
|
||||
/>
|
||||
{isQuestionsLoading && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[48px] w-full animate-pulse rounded-lg bg-gray-200"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isQuestionsLoading &&
|
||||
randomQuestions &&
|
||||
randomQuestions.length > 0 &&
|
||||
messages.length === 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="mb-2 text-xs font-normal text-gray-500">
|
||||
Some questions you might have about this lesson.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{randomQuestions?.map((question) => {
|
||||
return (
|
||||
<button
|
||||
key={`chat-${question}`}
|
||||
className="flex h-full self-start rounded-md bg-yellow-500/10 px-3 py-2 text-left text-sm text-black hover:bg-yellow-500/20"
|
||||
onClick={() => {
|
||||
handleSubmitInput(question);
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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 guide..."
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
.prose ul li > code,
|
||||
.prose ol li > code,
|
||||
p code,
|
||||
a > code,
|
||||
strong > code,
|
||||
em > code,
|
||||
h1 > code,
|
||||
h2 > code,
|
||||
h3 > code {
|
||||
background: #ebebeb !important;
|
||||
color: currentColor !important;
|
||||
font-size: 14px;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
.course-ai-content.course-content.prose ul li > code,
|
||||
.course-ai-content.course-content.prose ol li > code,
|
||||
.course-ai-content.course-content.prose p code,
|
||||
.course-ai-content.course-content.prose a > code,
|
||||
.course-ai-content.course-content.prose strong > code,
|
||||
.course-ai-content.course-content.prose em > code,
|
||||
.course-ai-content.course-content.prose h1 > code,
|
||||
.course-ai-content.course-content.prose h2 > code,
|
||||
.course-ai-content.course-content.prose h3 > code,
|
||||
.course-notes-content.prose ul li > code,
|
||||
.course-notes-content.prose ol li > code,
|
||||
.course-notes-content.prose p code,
|
||||
.course-notes-content.prose a > code,
|
||||
.course-notes-content.prose strong > code,
|
||||
.course-notes-content.prose em > code,
|
||||
.course-notes-content.prose h1 > code,
|
||||
.course-notes-content.prose h2 > code,
|
||||
.course-notes-content.prose h3 > code {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.course-ai-content pre {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.course-ai-content pre::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.course-ai-content pre,
|
||||
.course-notes-content pre {
|
||||
overflow: scroll;
|
||||
font-size: 15px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.prose ul li > code:before,
|
||||
p > code:before,
|
||||
.prose ul li > code:after,
|
||||
.prose ol li > code:before,
|
||||
p > code:before,
|
||||
.prose ol li > code:after,
|
||||
.course-content h1 > code:after,
|
||||
.course-content h1 > code:before,
|
||||
.course-content h2 > code:after,
|
||||
.course-content h2 > code:before,
|
||||
.course-content h3 > code:after,
|
||||
.course-content h3 > code:before,
|
||||
.course-content h4 > code:after,
|
||||
.course-content h4 > code:before,
|
||||
p > code:after,
|
||||
a > code:after,
|
||||
a > code:before {
|
||||
content: '' !important;
|
||||
}
|
||||
|
||||
.course-content.prose ul li > code,
|
||||
.course-content.prose ol li > code,
|
||||
.course-content p code,
|
||||
.course-content a > code,
|
||||
.course-content strong > code,
|
||||
.course-content em > code,
|
||||
.course-content h1 > code,
|
||||
.course-content h2 > code,
|
||||
.course-content h3 > code,
|
||||
.course-content table code {
|
||||
background: #f4f4f5 !important;
|
||||
border: 1px solid #282a36 !important;
|
||||
color: #282a36 !important;
|
||||
padding: 2px 4px;
|
||||
border-radius: 5px;
|
||||
font-size: 16px !important;
|
||||
white-space: pre;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.course-content blockquote {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.course-content.prose blockquote h1,
|
||||
.course-content.prose blockquote h2,
|
||||
.course-content.prose blockquote h3,
|
||||
.course-content.prose blockquote h4 {
|
||||
font-style: normal;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.course-content.prose ul li > code:before,
|
||||
.course-content p > code:before,
|
||||
.course-content.prose ul li > code:after,
|
||||
.course-content p > code:after,
|
||||
.course-content h2 > code:after,
|
||||
.course-content h2 > code:before,
|
||||
.course-content table code:before,
|
||||
.course-content table code:after,
|
||||
.course-content a > code:after,
|
||||
.course-content a > code:before,
|
||||
.course-content h2 code:after,
|
||||
.course-content h2 code:before,
|
||||
.course-content h2 code:after,
|
||||
.course-content h2 code:before {
|
||||
content: '' !important;
|
||||
}
|
||||
|
||||
.course-content table {
|
||||
border-collapse: collapse;
|
||||
border: 1px solid black;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.course-content table td,
|
||||
.course-content table th {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import './AIGuideContent.css';
|
||||
import { AIGuideRegenerate } from './AIGuideRegenerate';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { LoadingChip } from '../LoadingChip';
|
||||
|
||||
type AIGuideContentProps = {
|
||||
html: string;
|
||||
onRegenerate?: (prompt?: string) => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function AIGuideContent(props: AIGuideContentProps) {
|
||||
const { html, onRegenerate, isLoading } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative mx-auto w-full max-w-4xl',
|
||||
isLoading && 'min-h-full',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="course-content prose-h1:leading-[1.15] prose prose-lg prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800 max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm mt-8 max-w-full text-black max-lg:mt-4 max-lg:text-base [&>h1]:text-balance"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
{isLoading && !html && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onRegenerate && !isLoading && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<AIGuideRegenerate onRegenerate={onRegenerate} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { PenSquare, RefreshCcw } 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';
|
||||
|
||||
type AIGuideRegenerateProps = {
|
||||
onRegenerate: (prompt?: string) => void;
|
||||
};
|
||||
|
||||
export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
|
||||
const { onRegenerate } = props;
|
||||
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClick(ref, () => setIsDropdownVisible(false));
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal
|
||||
onClose={() => {
|
||||
setShowUpgradeModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPromptModal && (
|
||||
<ModifyCoursePrompt
|
||||
description="Pass additional information to the AI to generate a guide."
|
||||
onClose={() => setShowPromptModal(false)}
|
||||
onSubmit={(prompt) => {
|
||||
setShowPromptModal(false);
|
||||
onRegenerate(prompt);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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-[170px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(false);
|
||||
onRegenerate();
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<RefreshCcw
|
||||
size={16}
|
||||
className="text-gray-400"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(false);
|
||||
setShowPromptModal(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"
|
||||
>
|
||||
<PenSquare
|
||||
size={16}
|
||||
className="text-gray-400"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
Modify Prompt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { generateGuide } from '../../helper/generate-ai-guide';
|
||||
import { getCourseFineTuneData } from '../../lib/ai';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { AIGuideContent } from './AIGuideContent';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { getAiGuideOptions } from '../../queries/ai-guide';
|
||||
import { LoadingChip } from '../LoadingChip';
|
||||
|
||||
type GenerateAIGuideProps = {
|
||||
onGuideSlugChange?: (guideSlug: string) => void;
|
||||
};
|
||||
|
||||
export function GenerateAIGuide(props: GenerateAIGuideProps) {
|
||||
const { onGuideSlugChange } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [html, setHtml] = useState('');
|
||||
const htmlRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const params = getUrlParams();
|
||||
const paramsTerm = params?.term;
|
||||
const paramsDepth = params?.depth;
|
||||
const paramsSrc = params?.src || 'search';
|
||||
if (!paramsTerm || !paramsDepth) {
|
||||
return;
|
||||
}
|
||||
|
||||
let paramsGoal = '';
|
||||
let paramsAbout = '';
|
||||
let paramsCustomInstructions = '';
|
||||
|
||||
const sessionId = params?.id;
|
||||
if (sessionId) {
|
||||
const fineTuneData = getCourseFineTuneData(sessionId);
|
||||
if (fineTuneData) {
|
||||
paramsGoal = fineTuneData.goal;
|
||||
paramsAbout = fineTuneData.about;
|
||||
paramsCustomInstructions = fineTuneData.customInstructions;
|
||||
}
|
||||
}
|
||||
|
||||
handleGenerateDocument({
|
||||
term: paramsTerm,
|
||||
depth: paramsDepth,
|
||||
instructions: paramsCustomInstructions,
|
||||
goal: paramsGoal,
|
||||
about: paramsAbout,
|
||||
src: paramsSrc,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleGenerateDocument = async (options: {
|
||||
term: string;
|
||||
depth: string;
|
||||
instructions?: string;
|
||||
goal?: string;
|
||||
about?: string;
|
||||
isForce?: boolean;
|
||||
prompt?: string;
|
||||
src?: string;
|
||||
}) => {
|
||||
const { term, depth, isForce, prompt, instructions, goal, about, src } =
|
||||
options;
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
window.location.href = '/ai';
|
||||
return;
|
||||
}
|
||||
|
||||
await generateGuide({
|
||||
term,
|
||||
depth,
|
||||
onDetailsChange: (details) => {
|
||||
const { guideId, guideSlug, creatorId, title } = details;
|
||||
|
||||
const guideData = {
|
||||
_id: guideId,
|
||||
userId: creatorId,
|
||||
title,
|
||||
html: htmlRef.current,
|
||||
keyword: term,
|
||||
depth,
|
||||
content,
|
||||
tokens: {
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
total: 0,
|
||||
},
|
||||
relatedTopics: [],
|
||||
deepDiveTopics: [],
|
||||
questions: [],
|
||||
viewCount: 0,
|
||||
lastVisitedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
queryClient.setQueryData(
|
||||
getAiGuideOptions(guideSlug).queryKey,
|
||||
guideData,
|
||||
);
|
||||
|
||||
onGuideSlugChange?.(guideSlug);
|
||||
window.history.replaceState(null, '', `/ai/guide/${guideSlug}`);
|
||||
},
|
||||
onLoadingChange: setIsLoading,
|
||||
onError: setError,
|
||||
instructions,
|
||||
goal,
|
||||
about,
|
||||
isForce,
|
||||
prompt,
|
||||
src,
|
||||
onHtmlChange: (html) => {
|
||||
htmlRef.current = html;
|
||||
setHtml(html);
|
||||
},
|
||||
onStreamingChange: setIsStreaming,
|
||||
});
|
||||
};
|
||||
|
||||
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 <AIGuideContent html={html} />;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AIGuideContent } from './AIGuideContent';
|
||||
import { getAiGuideOptions } from '../../queries/ai-guide';
|
||||
|
||||
type GetAIGuideProps = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export function GetAIGuide(props: GetAIGuideProps) {
|
||||
const { slug: documentSlug } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const { data: aiGuide, error: queryError } = useQuery(
|
||||
{
|
||||
...getAiGuideOptions(documentSlug),
|
||||
enabled: !!documentSlug,
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiGuide) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [aiGuide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setError(queryError.message);
|
||||
}, [queryError]);
|
||||
|
||||
const handleRegenerateDocument = async (prompt?: string) => {
|
||||
// if (!aiDocument) {
|
||||
// return;
|
||||
// }
|
||||
// queryClient.setQueryData(
|
||||
// getAiDocumentOptions({ documentSlug: documentSlug }).queryKey,
|
||||
// {
|
||||
// ...aiDocument,
|
||||
// title: '',
|
||||
// difficulty: '',
|
||||
// modules: [],
|
||||
// },
|
||||
// );
|
||||
// await generateDocument({
|
||||
// term: aiDocument.keyword,
|
||||
// difficulty: aiDocument.difficulty,
|
||||
// slug: documentSlug,
|
||||
// prompt,
|
||||
// onDocumentChange: (document) => {
|
||||
// queryClient.setQueryData(
|
||||
// getAiDocumentOptions({ documentSlug: documentSlug }).queryKey,
|
||||
// {
|
||||
// ...aiDocument,
|
||||
// title: aiDocument.title,
|
||||
// difficulty: aiDocument.difficulty,
|
||||
// content: document,
|
||||
// },
|
||||
// );
|
||||
// },
|
||||
// onLoadingChange: (isNewLoading) => {
|
||||
// setIsRegenerating(isNewLoading);
|
||||
// if (!isNewLoading) {
|
||||
// // TODO: Update progress
|
||||
// }
|
||||
// },
|
||||
// onError: setError,
|
||||
// isForce: true,
|
||||
// });
|
||||
};
|
||||
|
||||
return <AIGuideContent html={aiGuide?.html || ''} />;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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 { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import {
|
||||
listUserAIGuidesOptions,
|
||||
type ListUserAIGuidesQuery,
|
||||
} from '../../queries/ai-guide';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import { AICourseSearch } from '../GenerateCourse/AICourseSearch';
|
||||
import { AIGuideCard } from '../AIGuide/AIGuideCard';
|
||||
|
||||
export function UserGuidesList() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||
|
||||
const [pageState, setPageState] = useState<ListUserAIGuidesQuery>({
|
||||
perPage: '21',
|
||||
currPage: '1',
|
||||
query: '',
|
||||
});
|
||||
|
||||
const { data: userAiGuides, isFetching: isUserAiGuidesLoading } = useQuery(
|
||||
listUserAIGuidesOptions(pageState),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsInitialLoading(false);
|
||||
}, [userAiGuides]);
|
||||
|
||||
const guides = userAiGuides?.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 = isUserAiGuidesLoading || isInitialLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradePopup && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
|
||||
)}
|
||||
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
disabled={isAnyLoading}
|
||||
placeholder="Search guides..."
|
||||
/>
|
||||
|
||||
{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 guides...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated
|
||||
? `You have generated ${userAiGuides?.totalCount} guides so far.`
|
||||
: 'Sign up or login to generate your first guide. Takes 2s to do so.'}
|
||||
</p>
|
||||
|
||||
{isUserAuthenticated && !isAnyLoading && guides.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">
|
||||
{guides.map((guide) => (
|
||||
<AIGuideCard key={guide._id} guide={guide} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
totalCount={userAiGuides?.totalCount || 0}
|
||||
totalPages={userAiGuides?.totalPages || 0}
|
||||
currPage={Number(userAiGuides?.currPage || 1)}
|
||||
perPage={Number(userAiGuides?.perPage || 10)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && guides.length === 0 && (
|
||||
<AITutorTallMessage
|
||||
title={
|
||||
isUserAuthenticated ? 'No guides found' : 'Sign up or login'
|
||||
}
|
||||
subtitle={
|
||||
isUserAuthenticated
|
||||
? "You haven't generated any guides yet."
|
||||
: 'Takes 2s to sign up and generate your first guide.'
|
||||
}
|
||||
icon={BookOpen}
|
||||
buttonText={
|
||||
isUserAuthenticated
|
||||
? 'Create your first guide'
|
||||
: 'Sign up or login'
|
||||
}
|
||||
onButtonClick={() => {
|
||||
if (isUserAuthenticated) {
|
||||
window.location.href = '/ai';
|
||||
} else {
|
||||
showLoginPopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user