mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-12 17:51:53 +08:00
Compare commits
43 Commits
7d6ad2c88d
...
feat/quest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10cafe591e | ||
|
|
1309b2fab3 | ||
|
|
2420a207c3 | ||
|
|
f79733d8b7 | ||
|
|
df14860a14 | ||
|
|
0f8f6eea57 | ||
|
|
7329936822 | ||
|
|
ef7397cf4a | ||
|
|
a64587b836 | ||
|
|
1b6029a04e | ||
|
|
e9fd3f0a57 | ||
|
|
288032cb78 | ||
|
|
10e7ec386c | ||
|
|
762ec5c93c | ||
|
|
eb4d969ae5 | ||
|
|
79e274190f | ||
|
|
cca807248e | ||
|
|
eee75bc923 | ||
|
|
423cc80e57 | ||
|
|
e0da80eef7 | ||
|
|
aad48d98dc | ||
|
|
83720b387c | ||
|
|
b0c3b1505c | ||
|
|
ae681a58b8 | ||
|
|
ab761c792e | ||
|
|
6a83882eae | ||
|
|
06c12f4d72 | ||
|
|
413748a224 | ||
|
|
8430f177f0 | ||
|
|
460200ee5a | ||
|
|
e8f5a06676 | ||
|
|
73e3b955f2 | ||
|
|
d840d7e27d | ||
|
|
0af6f9e987 | ||
|
|
ae790470fe | ||
|
|
1c73ab3c1d | ||
|
|
06db9a98d0 | ||
|
|
71c147a0ef | ||
|
|
f17f4b1403 | ||
|
|
ea1df049a5 | ||
|
|
2364eb9725 | ||
|
|
0479911df5 | ||
|
|
25967a85e1 |
@@ -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 }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
|
||||
58
src/components/AIRoadmap/AIRoadmap.css
Normal file
58
src/components/AIRoadmap/AIRoadmap.css
Normal 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;
|
||||
}
|
||||
167
src/components/AIRoadmap/AIRoadmap.tsx
Normal file
167
src/components/AIRoadmap/AIRoadmap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/components/AIRoadmap/AIRoadmapActions.tsx
Normal file
116
src/components/AIRoadmap/AIRoadmapActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/AIRoadmap/AIRoadmapCard.tsx
Normal file
49
src/components/AIRoadmap/AIRoadmapCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
371
src/components/AIRoadmap/AIRoadmapChat.tsx
Normal file
371
src/components/AIRoadmap/AIRoadmapChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
src/components/AIRoadmap/AIRoadmapContent.tsx
Normal file
117
src/components/AIRoadmap/AIRoadmapContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
src/components/AIRoadmap/AIRoadmapRegenerate.tsx
Normal file
298
src/components/AIRoadmap/AIRoadmapRegenerate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/components/AIRoadmap/GenerateAIRoadmap.tsx
Normal file
115
src/components/AIRoadmap/GenerateAIRoadmap.tsx
Normal 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} />;
|
||||
}
|
||||
146
src/components/AIRoadmap/UserRoadmapsList.tsx
Normal file
146
src/components/AIRoadmap/UserRoadmapsList.tsx
Normal 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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
363
src/components/ContentGenerator/QuestionAnswerChat.tsx
Normal file
363
src/components/ContentGenerator/QuestionAnswerChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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!}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
98
src/components/GenerateGuide/UpdatePreferences.tsx
Normal file
98
src/components/GenerateGuide/UpdatePreferences.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' />
|
||||
|
||||
101
src/components/NavigationDropdownMenu.tsx
Normal file
101
src/components/NavigationDropdownMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
60
src/components/TopNavDropdowns/AIDropdownMenu.tsx
Normal file
60
src/components/TopNavDropdowns/AIDropdownMenu.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
63
src/components/TopNavDropdowns/RoadmapDropdownMenu.tsx
Normal file
63
src/components/TopNavDropdowns/RoadmapDropdownMenu.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
29
src/lib/ai-questions.ts
Normal 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');
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
src/pages/ai/roadmap/index.astro
Normal file
15
src/pages/ai/roadmap/index.astro
Normal 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>
|
||||
17
src/pages/ai/roadmaps.astro
Normal file
17
src/pages/ai/roadmaps.astro
Normal 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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
213
src/queries/ai-roadmap.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
37
src/queries/user-ai-session.ts
Normal file
37
src/queries/user-ai-session.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
@@ -5,6 +5,7 @@ export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnMount: false,
|
||||
enabled: !import.meta.env.SSR,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user