mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-11 11:43:58 +02:00
feat: ai document (#8793)
* Refactor AI course * Add AI course generation functionality * Add basic error handling * AI Document content * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * feat: regenerate guide * wip * wip * wip * wip * wip * fix: form ui * feat: update guide ui * refactor: update the course card * fix: term and redirects * Guide page UI improvements * Loading chip refactor * AI guide changes * Improve UI for ai guide content * Add AI guide * AI Guide chat * fix: stop streaming * fix: chat responsiveness * UI improvements for ai library * Guide listing UI update * User guides listing * Library guides listing UI * Library guides listing UI * Featured courses listing UI update * Staff picks UI changes * Community page UI design * Explore courses listing functionality * Improve UI for explore page * Implement guides functionality --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
"enabled": false
|
||||
},
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1749494681580
|
||||
"lastUpdateCheck": 1749465237682
|
||||
}
|
||||
}
|
@@ -73,6 +73,7 @@
|
||||
"npm-check-updates": "^18.0.1",
|
||||
"playwright": "^1.52.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"radix-ui": "^1.4.2",
|
||||
"react": "^19.1.0",
|
||||
"react-calendar-heatmap": "^1.10.0",
|
||||
"react-confetti": "^6.4.0",
|
||||
|
974
pnpm-lock.yaml
generated
974
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -219,7 +219,7 @@ export function AIChat(props: AIChatProps) {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
chatHistoryId: defaultChatHistoryId,
|
||||
messages: messages.slice(-10),
|
||||
messages,
|
||||
force,
|
||||
}),
|
||||
});
|
||||
@@ -283,8 +283,7 @@ export function AIChat(props: AIChatProps) {
|
||||
});
|
||||
},
|
||||
onDetails: (details) => {
|
||||
const detailsJson = JSON.parse(details);
|
||||
const chatHistoryId = detailsJson?.chatHistoryId;
|
||||
const chatHistoryId = details?.chatHistoryId;
|
||||
if (!chatHistoryId) {
|
||||
return;
|
||||
}
|
||||
|
@@ -33,7 +33,7 @@ export function AIChatCourse(props: AIChatCourseProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const courseSearchUrl = `/ai/search?term=${course?.keyword}&difficulty=${course?.difficulty}`;
|
||||
const courseSearchUrl = `/ai/course?term=${course?.keyword}&difficulty=${course?.difficulty}`;
|
||||
|
||||
return (
|
||||
<div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0">
|
||||
|
116
src/components/AIGuide/AIGuideActions.tsx
Normal file
116
src/components/AIGuide/AIGuideActions.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ArrowUpRightIcon, 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 AIGuideActionsType = {
|
||||
guideSlug: string;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export function AIGuideActions(props: AIGuideActionsType) {
|
||||
const { guideSlug, onDeleted } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const { mutate: deleteCourse, isPending: isDeleting } = useMutation(
|
||||
{
|
||||
mutationFn: async () => {
|
||||
return httpDelete(`/v1-delete-ai-guide/${guideSlug}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Guide deleted');
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey?.[0] === 'user-ai-guides',
|
||||
});
|
||||
onDeleted?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error?.message || 'Failed to delete guide');
|
||||
},
|
||||
},
|
||||
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/guide/${guideSlug}`}
|
||||
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"
|
||||
>
|
||||
<ArrowUpRightIcon className="h-3.5 w-3.5" />
|
||||
View Guide
|
||||
</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 Guide
|
||||
</>
|
||||
) : (
|
||||
'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);
|
||||
deleteCourse();
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
52
src/components/AIGuide/AIGuideCard.tsx
Normal file
52
src/components/AIGuide/AIGuideCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { ListUserAIGuidesResponse } from '../../queries/ai-guide';
|
||||
import { AIGuideActions } from './AIGuideActions';
|
||||
|
||||
type AIGuideCardProps = {
|
||||
guide: ListUserAIGuidesResponse['data'][number] & {
|
||||
html: string;
|
||||
};
|
||||
showActions?: boolean;
|
||||
};
|
||||
|
||||
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"
|
||||
dangerouslySetInnerHTML={{ __html: guide.html }}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-white to-transparent group-hover:from-gray-50 sm:h-16" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{showActions && guide.slug && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<AIGuideActions guideSlug={guide.slug} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/components/AIGuide/AILibraryLayout.tsx
Normal file
34
src/components/AIGuide/AILibraryLayout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import { AITutorHeader } from '../AITutor/AITutorHeader';
|
||||
import { AITutorLayout } from '../AITutor/AITutorLayout';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { LibraryTabs } from '../Library/LibraryTab';
|
||||
|
||||
type AILibraryLayoutProps = {
|
||||
activeTab: 'courses' | 'guides';
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AILibraryLayout(props: AILibraryLayoutProps) {
|
||||
const { activeTab, children } = props;
|
||||
|
||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||
|
||||
return (
|
||||
<AITutorLayout activeTab="library">
|
||||
{showUpgradePopup && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
|
||||
)}
|
||||
<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"
|
||||
onUpgradeClick={() => setShowUpgradePopup(true)}
|
||||
/>
|
||||
|
||||
<LibraryTabs activeTab={activeTab} />
|
||||
{children}
|
||||
</div>
|
||||
</AITutorLayout>
|
||||
);
|
||||
}
|
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AICourseCard } from '../GenerateCourse/AICourseCard';
|
||||
import { AILoadingState } from './AILoadingState';
|
||||
import { AITutorHeader } from './AITutorHeader';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import {
|
||||
@@ -13,14 +12,15 @@ import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import { AICourseSearch } from '../GenerateCourse/AICourseSearch';
|
||||
import { AITutorTallMessage } from './AITutorTallMessage';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { BookOpen, Loader2 } from 'lucide-react';
|
||||
import { humanizeNumber } from '../../lib/number';
|
||||
|
||||
export function AIExploreCourseListing() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||
|
||||
const [pageState, setPageState] = useState<ListExploreAiCoursesQuery>({
|
||||
perPage: '21',
|
||||
perPage: '42',
|
||||
currPage: '1',
|
||||
query: '',
|
||||
});
|
||||
@@ -36,6 +36,7 @@ export function AIExploreCourseListing() {
|
||||
}, [exploreAiCourses]);
|
||||
|
||||
const courses = exploreAiCourses?.data ?? [];
|
||||
const isAnyLoading = isExploreAiCoursesLoading || isInitialLoading;
|
||||
|
||||
useEffect(() => {
|
||||
const queryParams = getUrlParams();
|
||||
@@ -63,66 +64,91 @@ export function AIExploreCourseListing() {
|
||||
|
||||
<AITutorHeader
|
||||
title="Explore Courses"
|
||||
subtitle="Explore the AI courses created by community"
|
||||
onUpgradeClick={() => setShowUpgradePopup(true)}
|
||||
>
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AITutorHeader>
|
||||
/>
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
disabled={isAnyLoading}
|
||||
/>
|
||||
|
||||
{(isInitialLoading || isExploreAiCoursesLoading) && (
|
||||
<AILoadingState
|
||||
title="Loading courses"
|
||||
subtitle="This may take a moment..."
|
||||
/>
|
||||
{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 courses...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isExploreAiCoursesLoading && courses && 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">
|
||||
{courses.map((course) => (
|
||||
<AICourseCard
|
||||
key={course._id}
|
||||
course={course}
|
||||
showActions={false}
|
||||
showProgress={false}
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Community has generated{' '}
|
||||
{humanizeNumber(exploreAiCourses?.totalCount || 0)} courses
|
||||
</p>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Pagination
|
||||
variant="minimal"
|
||||
totalCount={exploreAiCourses?.totalCount || 0}
|
||||
totalPages={exploreAiCourses?.totalPages || 0}
|
||||
currPage={Number(exploreAiCourses?.currPage || 1)}
|
||||
perPage={Number(exploreAiCourses?.perPage || 21)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className=""
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
totalCount={exploreAiCourses?.totalCount || 0}
|
||||
totalPages={exploreAiCourses?.totalPages || 0}
|
||||
currPage={Number(exploreAiCourses?.currPage || 1)}
|
||||
perPage={Number(exploreAiCourses?.perPage || 21)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{courses && 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">
|
||||
{courses.map((course) => (
|
||||
<AICourseCard
|
||||
key={course._id}
|
||||
course={course}
|
||||
showActions={false}
|
||||
showProgress={false}
|
||||
variant="column"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isInitialLoading &&
|
||||
!isExploreAiCoursesLoading &&
|
||||
courses.length === 0 && (
|
||||
<AITutorTallMessage
|
||||
title="No courses found"
|
||||
subtitle="Try a different search or check back later."
|
||||
icon={BookOpen}
|
||||
buttonText="Create your first course"
|
||||
onButtonClick={() => {
|
||||
window.location.href = '/ai';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Pagination
|
||||
totalCount={exploreAiCourses?.totalCount || 0}
|
||||
totalPages={exploreAiCourses?.totalPages || 0}
|
||||
currPage={Number(exploreAiCourses?.currPage || 1)}
|
||||
perPage={Number(exploreAiCourses?.perPage || 21)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{courses.length === 0 && (
|
||||
<AITutorTallMessage
|
||||
title="No courses found"
|
||||
subtitle="Try a different search or check back later."
|
||||
icon={BookOpen}
|
||||
buttonText="Create your first course"
|
||||
onButtonClick={() => {
|
||||
window.location.href = '/ai';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -20,7 +20,7 @@ export function AIFeaturedCoursesListing() {
|
||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||
|
||||
const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({
|
||||
perPage: '21',
|
||||
perPage: '42',
|
||||
currPage: '1',
|
||||
query: '',
|
||||
});
|
||||
@@ -63,7 +63,8 @@ export function AIFeaturedCoursesListing() {
|
||||
)}
|
||||
|
||||
<AITutorHeader
|
||||
title="Featured Courses"
|
||||
title="Staff Picks"
|
||||
subtitle="Explore our hand-picked courses generated by AI"
|
||||
onUpgradeClick={() => setShowUpgradePopup(true)}
|
||||
>
|
||||
<AICourseSearch
|
||||
@@ -96,6 +97,7 @@ export function AIFeaturedCoursesListing() {
|
||||
course={course}
|
||||
showActions={false}
|
||||
showProgress={false}
|
||||
variant="column"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@@ -1,17 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AITutorLimits } from './AITutorLimits';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
|
||||
type AITutorHeaderProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onUpgradeClick: () => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AITutorHeader(props: AITutorHeaderProps) {
|
||||
const { title, onUpgradeClick, children } = props;
|
||||
const { title, subtitle, onUpgradeClick, children } = props;
|
||||
|
||||
const { data: limits } = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
@@ -20,20 +21,22 @@ export function AITutorHeader(props: AITutorHeaderProps) {
|
||||
|
||||
return (
|
||||
<div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="relative flex-shrink-0 top-0 lg:top-1 text-lg font-semibold">{title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<AITutorLimits
|
||||
used={used}
|
||||
limit={limit}
|
||||
isPaidUser={isPaidUser}
|
||||
isPaidUserLoading={isPaidUserLoading}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
|
||||
{children}
|
||||
<div className="flex w-full flex-row items-center justify-between gap-2">
|
||||
<div className="gap-2">
|
||||
<h2 className="relative top-0 mb-1 sm:mb-3 flex-shrink-0 text-2xl sm:text-3xl font-semibold lg:top-1">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && <p className="mb-4 text-sm text-gray-500">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a
|
||||
href="/ai"
|
||||
className="flex max-sm:hidden flex-row items-center gap-2 rounded-lg bg-black px-4 py-1.5 text-sm font-medium text-white"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
New
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -6,7 +6,7 @@ import { cn } from '../../lib/classname';
|
||||
|
||||
type AITutorLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
activeTab: AITutorTab;
|
||||
activeTab?: AITutorTab;
|
||||
wrapperClassName?: string;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
@@ -14,20 +14,20 @@ import { UserDropdown } from './UserDropdown';
|
||||
|
||||
type AITutorSidebarProps = {
|
||||
isFloating: boolean;
|
||||
activeTab: AITutorTab;
|
||||
activeTab?: AITutorTab;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const sidebarItems = [
|
||||
{
|
||||
key: 'new',
|
||||
label: 'New Course',
|
||||
label: 'New',
|
||||
href: '/ai',
|
||||
icon: Plus,
|
||||
},
|
||||
{
|
||||
key: 'courses',
|
||||
label: 'My Courses',
|
||||
key: 'library',
|
||||
label: 'Library',
|
||||
href: '/ai/courses',
|
||||
icon: BookOpen,
|
||||
},
|
||||
|
75
src/components/AITutor/BaseDropdown.tsx
Normal file
75
src/components/AITutor/BaseDropdown.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
type BaseDropdownProps<T extends string> = {
|
||||
value: T;
|
||||
options: readonly T[];
|
||||
onChange: (value: T) => void;
|
||||
icons?: Record<T, LucideIcon>;
|
||||
};
|
||||
|
||||
export function BaseDropdown<T extends string>(props: BaseDropdownProps<T>) {
|
||||
const { value, options, onChange, icons } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const Icon = icons?.[value];
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200 hover:text-black',
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon size={16} />}
|
||||
<span className="capitalize">{value}</span>
|
||||
<ChevronDown size={16} className={cn(isOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-1 flex flex-col overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
|
||||
{options.map((option) => {
|
||||
const OptionIcon = icons?.[option];
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-5 py-2 text-left text-sm capitalize hover:bg-gray-100',
|
||||
value === option && 'bg-gray-200 font-medium hover:bg-gray-200',
|
||||
)}
|
||||
>
|
||||
{OptionIcon && <OptionIcon size={16} />}
|
||||
{option}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,9 +1,7 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { BaseDropdown } from './BaseDropdown';
|
||||
import {
|
||||
difficultyLevels,
|
||||
type DifficultyLevel,
|
||||
difficultyLevels,
|
||||
type DifficultyLevel,
|
||||
} from '../GenerateCourse/AICourse';
|
||||
|
||||
type DifficultyDropdownProps = {
|
||||
@@ -14,56 +12,11 @@ type DifficultyDropdownProps = {
|
||||
export function DifficultyDropdown(props: DifficultyDropdownProps) {
|
||||
const { value, onChange } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200 hover:text-black',
|
||||
)}
|
||||
>
|
||||
<span className="capitalize">{value}</span>
|
||||
<ChevronDown size={16} className={cn(isOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-1 flex flex-col overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
|
||||
{difficultyLevels.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(level);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'px-5 py-2 text-left text-sm capitalize hover:bg-gray-100',
|
||||
value === level && 'bg-gray-200 font-medium hover:bg-gray-200',
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<BaseDropdown
|
||||
value={value}
|
||||
options={difficultyLevels}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
28
src/components/AITutor/NatureDropdown.tsx
Normal file
28
src/components/AITutor/NatureDropdown.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BaseDropdown } from './BaseDropdown';
|
||||
import { BookOpen, FileText } from 'lucide-react';
|
||||
|
||||
export const natureTypes = ['course', 'document'] as const;
|
||||
export type NatureType = (typeof natureTypes)[number];
|
||||
|
||||
const natureIcons = {
|
||||
course: BookOpen,
|
||||
document: FileText,
|
||||
} as const;
|
||||
|
||||
type NatureDropdownProps = {
|
||||
value: NatureType;
|
||||
onChange: (value: NatureType) => void;
|
||||
};
|
||||
|
||||
export function NatureDropdown(props: NatureDropdownProps) {
|
||||
const { value, onChange } = props;
|
||||
|
||||
return (
|
||||
<BaseDropdown
|
||||
value={value}
|
||||
options={natureTypes}
|
||||
onChange={onChange}
|
||||
icons={natureIcons}
|
||||
/>
|
||||
);
|
||||
}
|
229
src/components/ContentGenerator/ContentGenerator.tsx
Normal file
229
src/components/ContentGenerator/ContentGenerator.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
FileTextIcon,
|
||||
SparklesIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useId, useState, type FormEvent } 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 { cn } from '../../lib/classname';
|
||||
|
||||
const allowedFormats = ['course', 'guide', 'roadmap'] as const;
|
||||
type AllowedFormat = (typeof allowedFormats)[number];
|
||||
|
||||
export function ContentGenerator() {
|
||||
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [selectedFormat, setSelectedFormat] = useState<AllowedFormat>('course');
|
||||
|
||||
// guide options
|
||||
const [depth, setDepth] = useState('essentials');
|
||||
// course options
|
||||
const [difficulty, setDifficulty] = useState('beginner');
|
||||
|
||||
// fine-tune options
|
||||
const [showFineTuneOptions, setShowFineTuneOptions] = useState(false);
|
||||
const [about, setAbout] = useState('');
|
||||
const [goal, setGoal] = useState('');
|
||||
const [customInstructions, setCustomInstructions] = useState('');
|
||||
|
||||
const titleFieldId = useId();
|
||||
const fineTuneOptionsId = useId();
|
||||
|
||||
const allowedFormats: {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
value: AllowedFormat;
|
||||
}[] = [
|
||||
{
|
||||
label: 'Course',
|
||||
icon: BookOpenIcon,
|
||||
value: 'course',
|
||||
},
|
||||
{
|
||||
label: 'Guide',
|
||||
icon: FileTextIcon,
|
||||
value: 'guide',
|
||||
},
|
||||
];
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId = '';
|
||||
if (showFineTuneOptions) {
|
||||
clearFineTuneData();
|
||||
sessionId = storeFineTuneData({
|
||||
about,
|
||||
goal,
|
||||
customInstructions,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedFormat === 'course') {
|
||||
window.location.href = `/ai/course?term=${encodeURIComponent(title)}&difficulty=${difficulty}&id=${sessionId}&format=${selectedFormat}`;
|
||||
} else if (selectedFormat === 'guide') {
|
||||
window.location.href = `/ai/guide?term=${encodeURIComponent(title)}&depth=${depth}&id=${sessionId}&format=${selectedFormat}`;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window?.fireEvent({
|
||||
action: 'tutor_user',
|
||||
category: 'ai_tutor',
|
||||
label: 'Visited AI Course Page',
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const lastSessionId = getLastSessionId();
|
||||
if (!lastSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fineTuneData = getCourseFineTuneData(lastSessionId);
|
||||
if (!fineTuneData) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAbout(fineTuneData.about);
|
||||
setGoal(fineTuneData.goal);
|
||||
setCustomInstructions(fineTuneData.customInstructions);
|
||||
}, []);
|
||||
|
||||
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="relative">
|
||||
{isUpgradeModalOpen && (
|
||||
<UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} />
|
||||
)}
|
||||
{!isPaidUser && !isPaidUserLoading && isLoggedIn() && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 -translate-y-8 text-gray-500 max-md:hidden">
|
||||
You are on the free plan
|
||||
<button
|
||||
onClick={() => setIsUpgradeModalOpen(true)}
|
||||
className="ml-2 rounded-xl bg-yellow-600 px-2 py-1 text-sm text-white hover:opacity-80"
|
||||
>
|
||||
Upgrade to Pro
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="mb-0.5 text-center text-4xl font-semibold max-md:text-left max-md:text-xl lg:mb-3">
|
||||
What can I help you learn?
|
||||
</h1>
|
||||
<p className="text-center text-lg text-balance text-gray-600 max-md:text-left max-md:text-sm">
|
||||
Enter a topic below to generate a personalized course for it
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-10 space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor={titleFieldId} className="inline-block text-gray-500">
|
||||
What can I help you learn?
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id={titleFieldId}
|
||||
placeholder="Enter a topic"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="inline-block text-gray-500">
|
||||
Choose the format
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{allowedFormats.map((format) => {
|
||||
const isSelected = format.value === selectedFormat;
|
||||
|
||||
return (
|
||||
<FormatItem
|
||||
key={format.value}
|
||||
label={format.label}
|
||||
onClick={() => setSelectedFormat(format.value)}
|
||||
icon={format.icon}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFormat === 'guide' && (
|
||||
<GuideOptions depth={depth} setDepth={setDepth} />
|
||||
)}
|
||||
|
||||
{selectedFormat === 'course' && (
|
||||
<CourseOptions
|
||||
difficulty={difficulty}
|
||||
setDifficulty={setDifficulty}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-black p-4 text-white focus:outline-none"
|
||||
>
|
||||
<SparklesIcon className="size-4" />
|
||||
Generate
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
79
src/components/ContentGenerator/CourseOptions.tsx
Normal file
79
src/components/ContentGenerator/CourseOptions.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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>
|
||||
);
|
||||
}
|
29
src/components/ContentGenerator/FormatItem.tsx
Normal file
29
src/components/ContentGenerator/FormatItem.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type FormatItemProps = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: LucideIcon;
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
export function FormatItem(props: FormatItemProps) {
|
||||
const { label, onClick, icon: Icon, isSelected } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full flex-col items-center justify-center gap-2.5 rounded-xl border border-gray-200 p-2 py-8',
|
||||
isSelected
|
||||
? 'border-gray-400 font-medium bg-white'
|
||||
: 'bg-white text-gray-400 hover:bg-white hover:border-gray-300',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="size-6" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
72
src/components/ContentGenerator/GuideOptions.tsx
Normal file
72
src/components/ContentGenerator/GuideOptions.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
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>
|
||||
);
|
||||
}
|
@@ -47,7 +47,7 @@ type ChatHeaderButtonProps = {
|
||||
target?: string;
|
||||
};
|
||||
|
||||
function ChatHeaderButton(props: ChatHeaderButtonProps) {
|
||||
export function ChatHeaderButton(props: ChatHeaderButtonProps) {
|
||||
const { onClick, href, icon, children, className, target } = props;
|
||||
|
||||
const classNames = cn(
|
||||
|
@@ -4,6 +4,7 @@ import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { FineTuneCourse } from './FineTuneCourse';
|
||||
import { DifficultyDropdown } from '../AITutor/DifficultyDropdown';
|
||||
import { NatureDropdown, type NatureType } from '../AITutor/NatureDropdown';
|
||||
import {
|
||||
clearFineTuneData,
|
||||
getCourseFineTuneData,
|
||||
@@ -26,6 +27,7 @@ type AICourseProps = {};
|
||||
export function AICourse(props: AICourseProps) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [difficulty, setDifficulty] = useState<DifficultyLevel>('beginner');
|
||||
const [nature, setNature] = useState<NatureType>('course');
|
||||
|
||||
const [hasFineTuneData, setHasFineTuneData] = useState(false);
|
||||
const [about, setAbout] = useState('');
|
||||
@@ -81,7 +83,11 @@ export function AICourse(props: AICourseProps) {
|
||||
});
|
||||
}
|
||||
|
||||
window.location.href = `/ai/search?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}`;
|
||||
if (nature === 'course') {
|
||||
window.location.href = `/ai/course?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}&nature=${nature}`;
|
||||
} else {
|
||||
window.location.href = `/ai/document?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}&nature=${nature}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -131,6 +137,7 @@ export function AICourse(props: AICourseProps) {
|
||||
<div className="flex flex-col items-start justify-between gap-2 px-4 pb-4 md:flex-row md:items-center">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex flex-row gap-2">
|
||||
<NatureDropdown value={nature} onChange={setNature} />
|
||||
<DifficultyDropdown
|
||||
value={difficulty}
|
||||
onChange={setDifficulty}
|
||||
@@ -148,7 +155,6 @@ export function AICourse(props: AICourseProps) {
|
||||
id="fine-tune-checkbox"
|
||||
/>
|
||||
Explain more
|
||||
<span className="hidden md:inline"> for a better course</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +175,6 @@ export function AICourse(props: AICourseProps) {
|
||||
|
||||
<FineTuneCourse
|
||||
hasFineTuneData={hasFineTuneData}
|
||||
setHasFineTuneData={setHasFineTuneData}
|
||||
about={about}
|
||||
goal={goal}
|
||||
customInstructions={customInstructions}
|
||||
|
@@ -2,17 +2,24 @@ import type { AICourseWithLessonCount } from '../../queries/ai-course';
|
||||
import type { DifficultyLevel } from './AICourse';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { AICourseActions } from './AICourseActions';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type AICourseCardProps = {
|
||||
course: AICourseWithLessonCount;
|
||||
showActions?: boolean;
|
||||
showProgress?: boolean;
|
||||
variant?: 'row' | 'column';
|
||||
};
|
||||
|
||||
export function AICourseCard(props: AICourseCardProps) {
|
||||
const { course, showActions = true, showProgress = true } = props;
|
||||
const {
|
||||
course,
|
||||
showActions = true,
|
||||
showProgress = true,
|
||||
variant = 'row',
|
||||
} = props;
|
||||
|
||||
// Map difficulty to color
|
||||
const difficultyColor =
|
||||
{
|
||||
beginner: 'text-green-700',
|
||||
@@ -20,49 +27,65 @@ export function AICourseCard(props: AICourseCardProps) {
|
||||
advanced: 'text-purple-700',
|
||||
}[course.difficulty as DifficultyLevel] || 'text-gray-700';
|
||||
|
||||
// Calculate progress percentage
|
||||
const modulesCount = course.modules?.length || 0;
|
||||
const totalTopics = course.lessonCount || 0;
|
||||
const completedTopics = course.done?.length || 0;
|
||||
const progressPercentage =
|
||||
totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0;
|
||||
const updatedAgo = getRelativeTimeString(course?.updatedAt);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-grow flex-col">
|
||||
<div className="relative flex flex-grow">
|
||||
<a
|
||||
href={`/ai/${course.slug}`}
|
||||
className="hover:border-gray-3 00 group relative flex h-full min-h-[140px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:bg-gray-50"
|
||||
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' && 'flex-row sm:flex-row sm:items-center',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
|
||||
>
|
||||
{course.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="my-2 text-base font-semibold text-gray-900">
|
||||
{course.title}
|
||||
</h3>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-2">
|
||||
<div className="flex items-center text-xs text-gray-600">
|
||||
<BookOpen className="mr-1 h-3.5 w-3.5" />
|
||||
<span>{totalTopics} lessons</span>
|
||||
{/* 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>
|
||||
|
||||
{showProgress && totalTopics > 0 && (
|
||||
<h3 className="line-clamp-2 text-base font-semibold text-balance text-gray-900">
|
||||
{course.title
|
||||
?.replace(": A Beginner's Guide", '')
|
||||
?.replace(' for beginners', '')
|
||||
?.replace(': A Comprehensive Guide', '')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Course stats section */}
|
||||
<div className="mt-7 flex items-center gap-4 sm:gap-4">
|
||||
<div className="hidden items-center text-xs text-gray-600 sm:flex">
|
||||
<BookOpen className="mr-1 h-3.5 w-3.5" />
|
||||
<span>{modulesCount} modules</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-2 h-1.5 w-16 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-600"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
{progressPercentage}%
|
||||
</span>
|
||||
<BookOpen className="mr-1 h-3.5 w-3.5" />
|
||||
<span>{totalTopics} lessons</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProgress && totalTopics > 0 && (
|
||||
<>
|
||||
<span className="hidden text-gray-400 sm:inline">•</span>
|
||||
<div className="flex items-center">
|
||||
<span className="flex items-center text-xs font-medium text-gray-700">
|
||||
{progressPercentage}% complete
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
@@ -5,10 +5,12 @@ import { useDebounceValue } from '../../hooks/use-debounce';
|
||||
type AICourseSearchProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function AICourseSearch(props: AICourseSearchProps) {
|
||||
const { value: defaultValue, onChange } = props;
|
||||
const { value: defaultValue, onChange, placeholder, disabled } = props;
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(defaultValue);
|
||||
const debouncedSearchTerm = useDebounceValue(searchTerm, 500);
|
||||
@@ -30,16 +32,17 @@ export function AICourseSearch(props: AICourseSearchProps) {
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
return (
|
||||
<div className="relative w-64 max-sm:hidden">
|
||||
<div className="relative mb-4">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<SearchIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full rounded-md border border-gray-200 bg-white py-1.5 pl-10 pr-3 leading-5 placeholder-gray-500 focus:border-gray-300 focus:outline-hidden focus:ring-blue-500 disabled:opacity-70 sm:text-sm"
|
||||
placeholder="Search courses..."
|
||||
className="block w-full rounded-lg border border-gray-200 bg-white py-3 pr-3 pl-10 leading-5 placeholder-gray-500 focus:border-gray-300 focus:ring-blue-500 focus:outline-hidden disabled:opacity-70 sm:text-sm"
|
||||
placeholder={placeholder || 'Search courses...'}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,3 +1,6 @@
|
||||
import { useId } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type QuestionProps = {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
@@ -8,13 +11,18 @@ type QuestionProps = {
|
||||
|
||||
function Question(props: QuestionProps) {
|
||||
const { label, placeholder, value, onChange, autoFocus = false } = props;
|
||||
const questionId = useId();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<label className="border-y bg-gray-100 px-4 py-2.5 text-sm font-medium text-gray-700">
|
||||
<label
|
||||
htmlFor={questionId}
|
||||
className="border-y bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
id={questionId}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[80px] w-full resize-none px-4 py-3 text-gray-700 placeholder:text-gray-400 focus:outline-hidden"
|
||||
value={value}
|
||||
@@ -31,10 +39,10 @@ type FineTuneCourseProps = {
|
||||
goal: string;
|
||||
customInstructions: string;
|
||||
|
||||
setHasFineTuneData: (hasMetadata: boolean) => void;
|
||||
setAbout: (about: string) => void;
|
||||
setGoal: (goal: string) => void;
|
||||
setCustomInstructions: (customInstructions: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FineTuneCourse(props: FineTuneCourseProps) {
|
||||
@@ -46,7 +54,7 @@ export function FineTuneCourse(props: FineTuneCourseProps) {
|
||||
setAbout,
|
||||
setGoal,
|
||||
setCustomInstructions,
|
||||
setHasFineTuneData,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
if (!hasFineTuneData) {
|
||||
@@ -54,7 +62,7 @@ export function FineTuneCourse(props: FineTuneCourseProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-0 flex flex-col">
|
||||
<div className={cn('mt-0 flex flex-col', className)}>
|
||||
<Question
|
||||
label="Tell us about yourself"
|
||||
placeholder="e.g. I am a frontend developer and have good knowledge of HTML, CSS, and JavaScript."
|
||||
|
@@ -1,21 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { BookOpen, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import {
|
||||
listUserAiCoursesOptions,
|
||||
type ListUserAiCoursesQuery,
|
||||
} from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AITutorHeader } from '../AITutor/AITutorHeader';
|
||||
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import { AICourseCard } from './AICourseCard';
|
||||
import { AICourseSearch } from './AICourseSearch';
|
||||
import { AILoadingState } from '../AITutor/AILoadingState';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
export function UserCoursesList() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
@@ -60,28 +58,8 @@ export function UserCoursesList() {
|
||||
}
|
||||
}, [pageState]);
|
||||
|
||||
if (isUserAiCoursesLoading || isInitialLoading) {
|
||||
return (
|
||||
<AILoadingState
|
||||
title="Loading your courses"
|
||||
subtitle="This may take a moment..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
return (
|
||||
<AITutorTallMessage
|
||||
title="Sign up or login"
|
||||
subtitle="Takes 2s to sign up and generate your first course."
|
||||
icon={BookOpen}
|
||||
buttonText="Sign up or Login"
|
||||
onButtonClick={() => {
|
||||
showLoginPopup();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isUserAuthenticated = isLoggedIn();
|
||||
const isAnyLoading = isUserAiCoursesLoading || isInitialLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -89,60 +67,78 @@ export function UserCoursesList() {
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
|
||||
)}
|
||||
|
||||
<AITutorHeader
|
||||
title="Your Courses"
|
||||
onUpgradeClick={() => setShowUpgradePopup(true)}
|
||||
>
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AITutorHeader>
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
disabled={isAnyLoading}
|
||||
/>
|
||||
|
||||
{(isUserAiCoursesLoading || isInitialLoading) && (
|
||||
<AILoadingState
|
||||
title="Loading your courses"
|
||||
subtitle="This may take a moment..."
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{!isUserAiCoursesLoading && !isInitialLoading && courses.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">
|
||||
{courses.map((course) => (
|
||||
<AICourseCard key={course._id} course={course} />
|
||||
))}
|
||||
</div>
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated
|
||||
? `You have generated ${userAiCourses?.totalCount} courses so far.`
|
||||
: 'Sign up or login to generate your first course. Takes 2s to do so.'}
|
||||
</p>
|
||||
|
||||
<Pagination
|
||||
totalCount={userAiCourses?.totalCount || 0}
|
||||
totalPages={userAiCourses?.totalPages || 0}
|
||||
currPage={Number(userAiCourses?.currPage || 1)}
|
||||
perPage={Number(userAiCourses?.perPage || 10)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isUserAuthenticated && !isAnyLoading && courses.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{courses.map((course) => (
|
||||
<AICourseCard key={course._id} course={course} />
|
||||
))}
|
||||
|
||||
{!isUserAiCoursesLoading && !isInitialLoading && courses.length === 0 && (
|
||||
<AITutorTallMessage
|
||||
title="No courses found"
|
||||
subtitle="You haven't generated any courses yet."
|
||||
icon={BookOpen}
|
||||
buttonText="Create your first course"
|
||||
onButtonClick={() => {
|
||||
window.location.href = '/ai';
|
||||
}}
|
||||
/>
|
||||
<Pagination
|
||||
totalCount={userAiCourses?.totalCount || 0}
|
||||
totalPages={userAiCourses?.totalPages || 0}
|
||||
currPage={Number(userAiCourses?.currPage || 1)}
|
||||
perPage={Number(userAiCourses?.perPage || 10)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && courses.length === 0 && (
|
||||
<AITutorTallMessage
|
||||
title={
|
||||
isUserAuthenticated ? 'No courses found' : 'Sign up or login'
|
||||
}
|
||||
subtitle={
|
||||
isUserAuthenticated
|
||||
? "You haven't generated any courses yet."
|
||||
: 'Takes 2s to sign up and generate your first course.'
|
||||
}
|
||||
icon={BookOpen}
|
||||
buttonText={
|
||||
isUserAuthenticated
|
||||
? 'Create your first course'
|
||||
: 'Sign up or login'
|
||||
}
|
||||
onButtonClick={() => {
|
||||
if (isUserAuthenticated) {
|
||||
window.location.href = '/ai';
|
||||
} else {
|
||||
showLoginPopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
231
src/components/GenerateGuide/AIGuide.tsx
Normal file
231
src/components/GenerateGuide/AIGuide.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { generateGuide } from '../../helper/generate-ai-guide';
|
||||
import { shuffle } from '../../helper/shuffle';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
aiGuideSuggestionsOptions,
|
||||
getAiGuideOptions,
|
||||
} from '../../queries/ai-guide';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AITutorLayout } from '../AITutor/AITutorLayout';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { AIGuideChat } from './AIGuideChat';
|
||||
import { AIGuideContent } from './AIGuideContent';
|
||||
import { GenerateAIGuide } from './GenerateAIGuide';
|
||||
|
||||
type AIGuideProps = {
|
||||
guideSlug?: string;
|
||||
};
|
||||
|
||||
export function AIGuide(props: AIGuideProps) {
|
||||
const { guideSlug: defaultGuideSlug } = props;
|
||||
const [guideSlug, setGuideSlug] = useState(defaultGuideSlug);
|
||||
|
||||
const toast = useToast();
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [regeneratedHtml, setRegeneratedHtml] = useState<string | null>(null);
|
||||
|
||||
// only fetch the guide if the guideSlug is provided
|
||||
// otherwise we are still generating the guide
|
||||
const { data: aiGuide, isLoading: isLoadingBySlug } = useQuery(
|
||||
getAiGuideOptions(guideSlug),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const { data: aiGuideSuggestions, isLoading: isAiGuideSuggestionsLoading } =
|
||||
useQuery(
|
||||
{
|
||||
...aiGuideSuggestionsOptions(guideSlug),
|
||||
enabled: !!guideSlug && !!isLoggedIn(),
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const randomQuestions = useMemo(() => {
|
||||
return shuffle(aiGuideSuggestions?.questions || []).slice(0, 4);
|
||||
}, [aiGuideSuggestions]);
|
||||
const relatedTopics = useMemo(() => {
|
||||
return shuffle(aiGuideSuggestions?.relatedTopics || []).slice(0, 2);
|
||||
}, [aiGuideSuggestions]);
|
||||
const deepDiveTopics = useMemo(() => {
|
||||
return shuffle(aiGuideSuggestions?.deepDiveTopics || []).slice(0, 2);
|
||||
}, [aiGuideSuggestions]);
|
||||
|
||||
const handleRegenerate = async (prompt?: string) => {
|
||||
flushSync(() => {
|
||||
setIsRegenerating(true);
|
||||
setRegeneratedHtml(null);
|
||||
});
|
||||
|
||||
queryClient.cancelQueries(getAiGuideOptions(guideSlug));
|
||||
queryClient.setQueryData(getAiGuideOptions(guideSlug).queryKey, (old) => {
|
||||
if (!old) {
|
||||
return old;
|
||||
}
|
||||
|
||||
return {
|
||||
...old,
|
||||
content: '',
|
||||
html: '',
|
||||
};
|
||||
});
|
||||
|
||||
await generateGuide({
|
||||
slug: aiGuide?.slug || '',
|
||||
term: aiGuide?.keyword || '',
|
||||
depth: aiGuide?.depth || '',
|
||||
prompt,
|
||||
onStreamingChange: setIsRegenerating,
|
||||
onHtmlChange: setRegeneratedHtml,
|
||||
onFinish: () => {
|
||||
setIsRegenerating(false);
|
||||
queryClient.invalidateQueries(getAiGuideOptions(guideSlug));
|
||||
},
|
||||
isForce: true,
|
||||
onError: (error) => {
|
||||
toast.error(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AITutorLayout
|
||||
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden bg-white"
|
||||
containerClassName="h-[calc(100vh-49px)] overflow-hidden relative"
|
||||
>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
||||
)}
|
||||
|
||||
<div className="grow overflow-y-auto p-4 pt-0">
|
||||
{guideSlug && (
|
||||
<AIGuideContent
|
||||
html={regeneratedHtml || aiGuide?.html || ''}
|
||||
onRegenerate={handleRegenerate}
|
||||
isLoading={isLoadingBySlug || isRegenerating}
|
||||
/>
|
||||
)}
|
||||
{!guideSlug && <GenerateAIGuide onGuideSlugChange={setGuideSlug} />}
|
||||
|
||||
{aiGuide && !isRegenerating && (
|
||||
<div className="mx-auto mt-12 mb-12 max-w-4xl">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<ListSuggestions
|
||||
title="Related Topics"
|
||||
suggestions={relatedTopics}
|
||||
depth="essentials"
|
||||
isLoading={isAiGuideSuggestionsLoading}
|
||||
currentGuideTitle={aiGuide.title}
|
||||
/>
|
||||
|
||||
<ListSuggestions
|
||||
title="Dive Deeper"
|
||||
suggestions={deepDiveTopics}
|
||||
depth="detailed"
|
||||
isLoading={isAiGuideSuggestionsLoading}
|
||||
currentGuideTitle={aiGuide.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AIGuideChat
|
||||
guideSlug={guideSlug}
|
||||
isGuideLoading={!aiGuide}
|
||||
onUpgrade={() => setShowUpgradeModal(true)}
|
||||
randomQuestions={randomQuestions}
|
||||
isQuestionsLoading={isAiGuideSuggestionsLoading}
|
||||
/>
|
||||
</AITutorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
type ListSuggestionsProps = {
|
||||
currentGuideTitle?: string;
|
||||
title: string;
|
||||
suggestions: string[];
|
||||
depth: string;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function ListSuggestions(props: ListSuggestionsProps) {
|
||||
const { title, suggestions, depth, isLoading, currentGuideTitle } = props;
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-gray-300 bg-linear-to-br from-gray-100 to-gray-50 shadow-xs transition-all duration-200">
|
||||
<div className="border-b border-gray-200 bg-white px-5 py-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{depth === 'essentials'
|
||||
? 'Explore related concepts to expand your knowledge'
|
||||
: 'Take a deeper dive into specific areas'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 w-full animate-pulse rounded-lg bg-white"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && suggestions?.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{suggestions.map((topic) => {
|
||||
const topicTerm =
|
||||
depth === 'essentials'
|
||||
? `I have covered the basics of ${currentGuideTitle} and want to learn more about ${topic}`
|
||||
: `I have covered the basics of ${currentGuideTitle} and want to dive deeper into ${topic}`;
|
||||
const url = `/ai/guide?term=${encodeURIComponent(topicTerm)}&depth=${depth}&id=&format=guide`;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={topic}
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="group/item flex items-center justify-between rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 transition-all duration-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
<span className="flex-1 truncate group-hover/item:text-gray-900">
|
||||
{topic}
|
||||
</span>
|
||||
<ExternalLink className="ml-2 size-4 text-gray-400 group-hover/item:text-gray-600" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && suggestions?.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="mb-3 rounded-full bg-gray-100 p-3">
|
||||
<svg
|
||||
className="h-6 w-6 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">No suggestions available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
381
src/components/GenerateGuide/AIGuideChat.tsx
Normal file
381
src/components/GenerateGuide/AIGuideChat.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useChat, type ChatMessage } from '../../hooks/use-chat';
|
||||
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
BotIcon,
|
||||
LockIcon,
|
||||
MessageCircleIcon,
|
||||
PauseCircleIcon,
|
||||
SendIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { ChatHeaderButton } from '../FrameRenderer/RoadmapFloatingChat';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { markdownToHtml } from '../../lib/markdown';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { LoadingChip } from '../LoadingChip';
|
||||
import { getTailwindScreenDimension } from '../../lib/is-mobile';
|
||||
|
||||
type AIGuideChatProps = {
|
||||
guideSlug?: string;
|
||||
isGuideLoading?: boolean;
|
||||
onUpgrade?: () => void;
|
||||
isQuestionsLoading?: boolean;
|
||||
randomQuestions?: string[];
|
||||
};
|
||||
|
||||
export function AIGuideChat(props: AIGuideChatProps) {
|
||||
const {
|
||||
guideSlug,
|
||||
isGuideLoading,
|
||||
onUpgrade,
|
||||
randomQuestions,
|
||||
isQuestionsLoading,
|
||||
} = props;
|
||||
|
||||
const scrollareaRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const [isChatOpen, setIsChatOpen] = useState(true);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const {
|
||||
data: tokenUsage,
|
||||
isLoading: isTokenUsageLoading,
|
||||
refetch: refetchTokenUsage,
|
||||
} = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
|
||||
const {
|
||||
data: userBillingDetails,
|
||||
isLoading: isBillingDetailsLoading,
|
||||
refetch: refetchBillingDetails,
|
||||
} = useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||
const isPaidUser = userBillingDetails?.status === 'active';
|
||||
|
||||
const {
|
||||
messages,
|
||||
status,
|
||||
streamedMessageHtml,
|
||||
sendMessages,
|
||||
setMessages,
|
||||
stop,
|
||||
} = useChat({
|
||||
endpoint: `${import.meta.env.PUBLIC_API_URL}/v1-ai-guide-chat`,
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
data: {
|
||||
guideSlug,
|
||||
},
|
||||
onFinish: () => {
|
||||
refetchTokenUsage();
|
||||
},
|
||||
});
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(behavior: 'smooth' | 'instant' = 'smooth') => {
|
||||
scrollareaRef.current?.scrollTo({
|
||||
top: scrollareaRef.current.scrollHeight,
|
||||
behavior,
|
||||
});
|
||||
},
|
||||
[scrollareaRef],
|
||||
);
|
||||
|
||||
const isStreamingMessage = status === 'streaming';
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
const handleSubmitInput = useCallback(
|
||||
(defaultInputValue?: string) => {
|
||||
const message = defaultInputValue || inputValue;
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStreamingMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newMessages: ChatMessage[] = [
|
||||
...messages,
|
||||
{
|
||||
role: 'user',
|
||||
content: message,
|
||||
html: markdownToHtml(message),
|
||||
},
|
||||
];
|
||||
flushSync(() => {
|
||||
setMessages(newMessages);
|
||||
});
|
||||
sendMessages(newMessages);
|
||||
setInputValue('');
|
||||
},
|
||||
[inputValue, isStreamingMessage, messages, sendMessages, setMessages],
|
||||
);
|
||||
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const scrollArea = scrollareaRef.current;
|
||||
if (!scrollArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollArea;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold
|
||||
setShowScrollToBottom(!isAtBottom && messages.length > 0);
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollArea = scrollareaRef.current;
|
||||
if (!scrollArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollArea.addEventListener('scroll', checkScrollPosition);
|
||||
return () => scrollArea.removeEventListener('scroll', checkScrollPosition);
|
||||
}, [checkScrollPosition]);
|
||||
|
||||
const isLoading =
|
||||
isGuideLoading || isTokenUsageLoading || isBillingDetailsLoading;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const deviceType = getTailwindScreenDimension();
|
||||
const isMediumSize = ['sm', 'md'].includes(deviceType);
|
||||
|
||||
if (!isMediumSize) {
|
||||
const storedState = localStorage.getItem('chat-history-sidebar-open');
|
||||
setIsChatOpen(storedState === null ? true : storedState === 'true');
|
||||
} else {
|
||||
setIsChatOpen(!isMediumSize);
|
||||
}
|
||||
|
||||
setIsMobile(isMediumSize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
localStorage.setItem('chat-history-sidebar-open', isChatOpen.toString());
|
||||
}
|
||||
}, [isChatOpen, isMobile]);
|
||||
|
||||
if (!isChatOpen) {
|
||||
return (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center p-2">
|
||||
<button
|
||||
className="flex items-center justify-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow"
|
||||
onClick={() => {
|
||||
setIsChatOpen(true);
|
||||
}}
|
||||
>
|
||||
<MessageCircleIcon className="h-4 w-4" />
|
||||
<span className="text-sm">Open Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex h-full w-full max-w-full flex-col overflow-hidden border-l border-gray-200 bg-white md:relative md:max-w-[40%]">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-gray-200 bg-white p-2">
|
||||
<h2 className="flex items-center gap-2 text-sm font-medium">
|
||||
<BotIcon className="h-4 w-4" />
|
||||
AI Guide
|
||||
</h2>
|
||||
|
||||
<button
|
||||
className="mr-2 flex size-5 items-center justify-center rounded-md text-gray-500 hover:bg-gray-300 md:hidden"
|
||||
onClick={() => {
|
||||
setIsChatOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<LoadingChip message="Loading..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
html="Hello, how can I help you today?"
|
||||
isIntro
|
||||
/>
|
||||
{isQuestionsLoading && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[48px] w-full animate-pulse rounded-lg bg-gray-200"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isQuestionsLoading &&
|
||||
randomQuestions &&
|
||||
randomQuestions.length > 0 &&
|
||||
messages.length === 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="mb-2 text-xs font-normal text-gray-500">
|
||||
Some questions you might have about this lesson.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{randomQuestions?.map((question) => {
|
||||
return (
|
||||
<button
|
||||
key={`chat-${question}`}
|
||||
className="flex h-full self-start rounded-md bg-yellow-500/10 px-3 py-2 text-left text-sm text-black hover:bg-yellow-500/20"
|
||||
onClick={() => {
|
||||
handleSubmitInput(question);
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((chat, index) => {
|
||||
return (
|
||||
<RoadmapAIChatCard key={`chat-${index}`} {...chat} />
|
||||
);
|
||||
})}
|
||||
|
||||
{status === 'streaming' && !streamedMessageHtml && (
|
||||
<RoadmapAIChatCard role="assistant" html="Thinking..." />
|
||||
)}
|
||||
|
||||
{status === 'streaming' && streamedMessageHtml && (
|
||||
<RoadmapAIChatCard
|
||||
role="assistant"
|
||||
html={streamedMessageHtml}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasMessages || showScrollToBottom) && (
|
||||
<div className="flex flex-row justify-between gap-2 border-t border-gray-200 px-3 py-2">
|
||||
<ChatHeaderButton
|
||||
icon={<Trash2Icon className="h-3.5 w-3.5" />}
|
||||
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
|
||||
onClick={() => {
|
||||
setMessages([]);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</ChatHeaderButton>
|
||||
{showScrollToBottom && (
|
||||
<ChatHeaderButton
|
||||
icon={<ArrowDownIcon className="h-3.5 w-3.5" />}
|
||||
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
|
||||
onClick={() => {
|
||||
scrollToBottom('smooth');
|
||||
}}
|
||||
>
|
||||
Scroll to bottom
|
||||
</ChatHeaderButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-center border-t border-gray-200 text-sm">
|
||||
{isLimitExceeded && isLoggedIn() && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
|
||||
<LockIcon
|
||||
className="size-4 cursor-not-allowed"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<p className="cursor-not-allowed">
|
||||
Limit reached for today
|
||||
{isPaidUser ? '. Please wait until tomorrow.' : ''}
|
||||
</p>
|
||||
{!isPaidUser && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onUpgrade?.();
|
||||
}}
|
||||
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||
>
|
||||
Upgrade for more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (isStreamingMessage) {
|
||||
return;
|
||||
}
|
||||
handleSubmitInput();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask me anything about this guide..."
|
||||
className="w-full resize-none px-3 py-4 outline-none"
|
||||
/>
|
||||
|
||||
<button
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-zinc-500 hover:text-black disabled:opacity-50"
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status !== 'idle') {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSubmitInput();
|
||||
}}
|
||||
>
|
||||
{isStreamingMessage ? (
|
||||
<PauseCircleIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<SendIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
131
src/components/GenerateGuide/AIGuideContent.css
Normal file
131
src/components/GenerateGuide/AIGuideContent.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.prose ul li > code,
|
||||
.prose ol li > code,
|
||||
p code,
|
||||
a > code,
|
||||
strong > code,
|
||||
em > code,
|
||||
h1 > code,
|
||||
h2 > code,
|
||||
h3 > code {
|
||||
background: #ebebeb !important;
|
||||
color: currentColor !important;
|
||||
font-size: 14px;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
.course-ai-content.course-content.prose ul li > code,
|
||||
.course-ai-content.course-content.prose ol li > code,
|
||||
.course-ai-content.course-content.prose p code,
|
||||
.course-ai-content.course-content.prose a > code,
|
||||
.course-ai-content.course-content.prose strong > code,
|
||||
.course-ai-content.course-content.prose em > code,
|
||||
.course-ai-content.course-content.prose h1 > code,
|
||||
.course-ai-content.course-content.prose h2 > code,
|
||||
.course-ai-content.course-content.prose h3 > code,
|
||||
.course-notes-content.prose ul li > code,
|
||||
.course-notes-content.prose ol li > code,
|
||||
.course-notes-content.prose p code,
|
||||
.course-notes-content.prose a > code,
|
||||
.course-notes-content.prose strong > code,
|
||||
.course-notes-content.prose em > code,
|
||||
.course-notes-content.prose h1 > code,
|
||||
.course-notes-content.prose h2 > code,
|
||||
.course-notes-content.prose h3 > code {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.course-ai-content pre {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.course-ai-content pre::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.course-ai-content pre,
|
||||
.course-notes-content pre {
|
||||
overflow: scroll;
|
||||
font-size: 15px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.prose ul li > code:before,
|
||||
p > code:before,
|
||||
.prose ul li > code:after,
|
||||
.prose ol li > code:before,
|
||||
p > code:before,
|
||||
.prose ol li > code:after,
|
||||
.course-content h1 > code:after,
|
||||
.course-content h1 > code:before,
|
||||
.course-content h2 > code:after,
|
||||
.course-content h2 > code:before,
|
||||
.course-content h3 > code:after,
|
||||
.course-content h3 > code:before,
|
||||
.course-content h4 > code:after,
|
||||
.course-content h4 > code:before,
|
||||
p > code:after,
|
||||
a > code:after,
|
||||
a > code:before {
|
||||
content: '' !important;
|
||||
}
|
||||
|
||||
.course-content.prose ul li > code,
|
||||
.course-content.prose ol li > code,
|
||||
.course-content p code,
|
||||
.course-content a > code,
|
||||
.course-content strong > code,
|
||||
.course-content em > code,
|
||||
.course-content h1 > code,
|
||||
.course-content h2 > code,
|
||||
.course-content h3 > code,
|
||||
.course-content table code {
|
||||
background: #f4f4f5 !important;
|
||||
border: 1px solid #282a36 !important;
|
||||
color: #282a36 !important;
|
||||
padding: 2px 4px;
|
||||
border-radius: 5px;
|
||||
font-size: 16px !important;
|
||||
white-space: pre;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.course-content blockquote {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.course-content.prose blockquote h1,
|
||||
.course-content.prose blockquote h2,
|
||||
.course-content.prose blockquote h3,
|
||||
.course-content.prose blockquote h4 {
|
||||
font-style: normal;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.course-content.prose ul li > code:before,
|
||||
.course-content p > code:before,
|
||||
.course-content.prose ul li > code:after,
|
||||
.course-content p > code:after,
|
||||
.course-content h2 > code:after,
|
||||
.course-content h2 > code:before,
|
||||
.course-content table code:before,
|
||||
.course-content table code:after,
|
||||
.course-content a > code:after,
|
||||
.course-content a > code:before,
|
||||
.course-content h2 code:after,
|
||||
.course-content h2 code:before,
|
||||
.course-content h2 code:after,
|
||||
.course-content h2 code:before {
|
||||
content: '' !important;
|
||||
}
|
||||
|
||||
.course-content table {
|
||||
border-collapse: collapse;
|
||||
border: 1px solid black;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.course-content table td,
|
||||
.course-content table th {
|
||||
padding: 5px 10px;
|
||||
}
|
40
src/components/GenerateGuide/AIGuideContent.tsx
Normal file
40
src/components/GenerateGuide/AIGuideContent.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import './AIGuideContent.css';
|
||||
import { AIGuideRegenerate } from './AIGuideRegenerate';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { LoadingChip } from '../LoadingChip';
|
||||
|
||||
type AIGuideContentProps = {
|
||||
html: string;
|
||||
onRegenerate?: (prompt?: string) => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function AIGuideContent(props: AIGuideContentProps) {
|
||||
const { html, onRegenerate, isLoading } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative mx-auto w-full max-w-4xl',
|
||||
isLoading && 'min-h-full',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="course-content prose-h1:leading-[1.15] prose prose-lg prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800 max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm mt-8 max-w-full text-black max-lg:mt-4 max-lg:text-base [&>h1]:text-balance"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
{isLoading && !html && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onRegenerate && !isLoading && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<AIGuideRegenerate onRegenerate={onRegenerate} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
88
src/components/GenerateGuide/AIGuideRegenerate.tsx
Normal file
88
src/components/GenerateGuide/AIGuideRegenerate.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { PenSquare, RefreshCcw } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { ModifyCoursePrompt } from '../GenerateCourse/ModifyCoursePrompt';
|
||||
|
||||
type AIGuideRegenerateProps = {
|
||||
onRegenerate: (prompt?: string) => void;
|
||||
};
|
||||
|
||||
export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
|
||||
const { onRegenerate } = props;
|
||||
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClick(ref, () => setIsDropdownVisible(false));
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal
|
||||
onClose={() => {
|
||||
setShowUpgradeModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPromptModal && (
|
||||
<ModifyCoursePrompt
|
||||
description="Pass additional information to the AI to generate a guide."
|
||||
onClose={() => setShowPromptModal(false)}
|
||||
onSubmit={(prompt) => {
|
||||
setShowPromptModal(false);
|
||||
onRegenerate(prompt);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div ref={ref} className="relative flex items-stretch">
|
||||
<button
|
||||
className={cn('rounded-md px-2.5 text-gray-400 hover:text-black', {
|
||||
'text-black': isDropdownVisible,
|
||||
})}
|
||||
onClick={() => setIsDropdownVisible(!isDropdownVisible)}
|
||||
>
|
||||
<PenSquare className="text-current" size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
{isDropdownVisible && (
|
||||
<div className="absolute top-full right-0 min-w-[170px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(false);
|
||||
onRegenerate();
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<RefreshCcw
|
||||
size={16}
|
||||
className="text-gray-400"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(false);
|
||||
setShowPromptModal(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<PenSquare
|
||||
size={16}
|
||||
className="text-gray-400"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
Modify Prompt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
142
src/components/GenerateGuide/GenerateAIGuide.tsx
Normal file
142
src/components/GenerateGuide/GenerateAIGuide.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { generateGuide } from '../../helper/generate-ai-guide';
|
||||
import { getCourseFineTuneData } from '../../lib/ai';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { AIGuideContent } from './AIGuideContent';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { getAiGuideOptions } from '../../queries/ai-guide';
|
||||
import { LoadingChip } from '../LoadingChip';
|
||||
|
||||
type GenerateAIGuideProps = {
|
||||
onGuideSlugChange?: (guideSlug: string) => void;
|
||||
};
|
||||
|
||||
export function GenerateAIGuide(props: GenerateAIGuideProps) {
|
||||
const { onGuideSlugChange } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [html, setHtml] = useState('');
|
||||
const htmlRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const params = getUrlParams();
|
||||
const paramsTerm = params?.term;
|
||||
const paramsDepth = params?.depth;
|
||||
const paramsSrc = params?.src || 'search';
|
||||
if (!paramsTerm || !paramsDepth) {
|
||||
return;
|
||||
}
|
||||
|
||||
let paramsGoal = '';
|
||||
let paramsAbout = '';
|
||||
let paramsCustomInstructions = '';
|
||||
|
||||
const sessionId = params?.id;
|
||||
if (sessionId) {
|
||||
const fineTuneData = getCourseFineTuneData(sessionId);
|
||||
if (fineTuneData) {
|
||||
paramsGoal = fineTuneData.goal;
|
||||
paramsAbout = fineTuneData.about;
|
||||
paramsCustomInstructions = fineTuneData.customInstructions;
|
||||
}
|
||||
}
|
||||
|
||||
handleGenerateDocument({
|
||||
term: paramsTerm,
|
||||
depth: paramsDepth,
|
||||
instructions: paramsCustomInstructions,
|
||||
goal: paramsGoal,
|
||||
about: paramsAbout,
|
||||
src: paramsSrc,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleGenerateDocument = async (options: {
|
||||
term: string;
|
||||
depth: string;
|
||||
instructions?: string;
|
||||
goal?: string;
|
||||
about?: string;
|
||||
isForce?: boolean;
|
||||
prompt?: string;
|
||||
src?: string;
|
||||
}) => {
|
||||
const { term, depth, isForce, prompt, instructions, goal, about, src } =
|
||||
options;
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
window.location.href = '/ai';
|
||||
return;
|
||||
}
|
||||
|
||||
await generateGuide({
|
||||
term,
|
||||
depth,
|
||||
onDetailsChange: (details) => {
|
||||
const { guideId, guideSlug, creatorId, title } = details;
|
||||
|
||||
const guideData = {
|
||||
_id: guideId,
|
||||
userId: creatorId,
|
||||
title,
|
||||
html: htmlRef.current,
|
||||
keyword: term,
|
||||
depth,
|
||||
content,
|
||||
tokens: {
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
total: 0,
|
||||
},
|
||||
relatedTopics: [],
|
||||
deepDiveTopics: [],
|
||||
questions: [],
|
||||
viewCount: 0,
|
||||
lastVisitedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
queryClient.setQueryData(
|
||||
getAiGuideOptions(guideSlug).queryKey,
|
||||
guideData,
|
||||
);
|
||||
|
||||
onGuideSlugChange?.(guideSlug);
|
||||
window.history.replaceState(null, '', `/ai/guide/${guideSlug}`);
|
||||
},
|
||||
onLoadingChange: setIsLoading,
|
||||
onError: setError,
|
||||
instructions,
|
||||
goal,
|
||||
about,
|
||||
isForce,
|
||||
prompt,
|
||||
src,
|
||||
onHtmlChange: (html) => {
|
||||
htmlRef.current = html;
|
||||
setHtml(html);
|
||||
},
|
||||
onStreamingChange: setIsStreaming,
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">{error}</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AIGuideContent html={html} />;
|
||||
}
|
84
src/components/GenerateGuide/GetAIGuide.tsx
Normal file
84
src/components/GenerateGuide/GetAIGuide.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AIGuideContent } from './AIGuideContent';
|
||||
import { getAiGuideOptions } from '../../queries/ai-guide';
|
||||
|
||||
type GetAIGuideProps = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export function GetAIGuide(props: GetAIGuideProps) {
|
||||
const { slug: documentSlug } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const { data: aiGuide, error: queryError } = useQuery(
|
||||
{
|
||||
...getAiGuideOptions(documentSlug),
|
||||
enabled: !!documentSlug,
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiGuide) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [aiGuide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setError(queryError.message);
|
||||
}, [queryError]);
|
||||
|
||||
const handleRegenerateDocument = async (prompt?: string) => {
|
||||
// if (!aiDocument) {
|
||||
// return;
|
||||
// }
|
||||
// queryClient.setQueryData(
|
||||
// getAiDocumentOptions({ documentSlug: documentSlug }).queryKey,
|
||||
// {
|
||||
// ...aiDocument,
|
||||
// title: '',
|
||||
// difficulty: '',
|
||||
// modules: [],
|
||||
// },
|
||||
// );
|
||||
// await generateDocument({
|
||||
// term: aiDocument.keyword,
|
||||
// difficulty: aiDocument.difficulty,
|
||||
// slug: documentSlug,
|
||||
// prompt,
|
||||
// onDocumentChange: (document) => {
|
||||
// queryClient.setQueryData(
|
||||
// getAiDocumentOptions({ documentSlug: documentSlug }).queryKey,
|
||||
// {
|
||||
// ...aiDocument,
|
||||
// title: aiDocument.title,
|
||||
// difficulty: aiDocument.difficulty,
|
||||
// content: document,
|
||||
// },
|
||||
// );
|
||||
// },
|
||||
// onLoadingChange: (isNewLoading) => {
|
||||
// setIsRegenerating(isNewLoading);
|
||||
// if (!isNewLoading) {
|
||||
// // TODO: Update progress
|
||||
// }
|
||||
// },
|
||||
// onError: setError,
|
||||
// isForce: true,
|
||||
// });
|
||||
};
|
||||
|
||||
return <AIGuideContent html={aiGuide?.html || ''} />;
|
||||
}
|
148
src/components/GenerateGuide/UserGuidesList.tsx
Normal file
148
src/components/GenerateGuide/UserGuidesList.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BookOpen, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import {
|
||||
listUserAIGuidesOptions,
|
||||
type ListUserAIGuidesQuery,
|
||||
} from '../../queries/ai-guide';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import { AICourseSearch } from '../GenerateCourse/AICourseSearch';
|
||||
import { AIGuideCard } from '../AIGuide/AIGuideCard';
|
||||
|
||||
export function UserGuidesList() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||
|
||||
const [pageState, setPageState] = useState<ListUserAIGuidesQuery>({
|
||||
perPage: '21',
|
||||
currPage: '1',
|
||||
query: '',
|
||||
});
|
||||
|
||||
const { data: userAiGuides, isFetching: isUserAiGuidesLoading } = useQuery(
|
||||
listUserAIGuidesOptions(pageState),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsInitialLoading(false);
|
||||
}, [userAiGuides]);
|
||||
|
||||
const guides = userAiGuides?.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const queryParams = getUrlParams();
|
||||
|
||||
setPageState({
|
||||
...pageState,
|
||||
currPage: queryParams?.p || '1',
|
||||
query: queryParams?.q || '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageState?.currPage !== '1' || pageState?.query !== '') {
|
||||
setUrlParams({
|
||||
p: pageState?.currPage || '1',
|
||||
q: pageState?.query || '',
|
||||
});
|
||||
} else {
|
||||
deleteUrlParam('p');
|
||||
deleteUrlParam('q');
|
||||
}
|
||||
}, [pageState]);
|
||||
|
||||
const isUserAuthenticated = isLoggedIn();
|
||||
const isAnyLoading = isUserAiGuidesLoading || isInitialLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradePopup && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
|
||||
)}
|
||||
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
disabled={isAnyLoading}
|
||||
placeholder="Search guides..."
|
||||
/>
|
||||
|
||||
{isAnyLoading && (
|
||||
<p className="mb-4 flex flex-row items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading your guides...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated
|
||||
? `You have generated ${userAiGuides?.totalCount} guides so far.`
|
||||
: 'Sign up or login to generate your first guide. Takes 2s to do so.'}
|
||||
</p>
|
||||
|
||||
{isUserAuthenticated && !isAnyLoading && guides.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{guides.map((guide) => (
|
||||
<AIGuideCard key={guide._id} guide={guide} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
totalCount={userAiGuides?.totalCount || 0}
|
||||
totalPages={userAiGuides?.totalPages || 0}
|
||||
currPage={Number(userAiGuides?.currPage || 1)}
|
||||
perPage={Number(userAiGuides?.perPage || 10)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && guides.length === 0 && (
|
||||
<AITutorTallMessage
|
||||
title={
|
||||
isUserAuthenticated ? 'No guides found' : 'Sign up or login'
|
||||
}
|
||||
subtitle={
|
||||
isUserAuthenticated
|
||||
? "You haven't generated any guides yet."
|
||||
: 'Takes 2s to sign up and generate your first guide.'
|
||||
}
|
||||
icon={BookOpen}
|
||||
buttonText={
|
||||
isUserAuthenticated
|
||||
? 'Create your first guide'
|
||||
: 'Sign up or login'
|
||||
}
|
||||
onButtonClick={() => {
|
||||
if (isUserAuthenticated) {
|
||||
window.location.href = '/ai';
|
||||
} else {
|
||||
showLoginPopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
53
src/components/Library/LibraryTab.tsx
Normal file
53
src/components/Library/LibraryTab.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { BookOpen, FileTextIcon, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type LibraryTabsProps = {
|
||||
activeTab: 'guides' | 'courses';
|
||||
};
|
||||
|
||||
export function LibraryTabs(props: LibraryTabsProps) {
|
||||
const { activeTab } = props;
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex gap-2 border-b border-gray-300">
|
||||
<LibraryTabButton
|
||||
isActive={activeTab === 'courses'}
|
||||
icon={BookOpen}
|
||||
label="Courses"
|
||||
href="/ai/courses"
|
||||
/>
|
||||
<LibraryTabButton
|
||||
isActive={activeTab === 'guides'}
|
||||
icon={FileTextIcon}
|
||||
label="Guides"
|
||||
href="/ai/guides"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LibraryTabButtonProps = {
|
||||
isActive: boolean;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
function LibraryTabButton(props: LibraryTabButtonProps) {
|
||||
const { isActive, icon: Icon, label, href } = props;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-t-md px-4 py-2 text-sm font-medium',
|
||||
isActive
|
||||
? 'bg-gray-300'
|
||||
: 'bg-gray-100 transition-colors hover:bg-gray-200',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
16
src/components/LoadingChip.tsx
Normal file
16
src/components/LoadingChip.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
type LoadingChipProps = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function LoadingChip(props: LoadingChipProps) {
|
||||
const { message = 'Please wait...' } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-300 bg-white py-2 pr-4 pl-3 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin text-gray-400" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -51,7 +51,7 @@ export function Pagination(props: PaginationProps) {
|
||||
onPageChange(currPage - 1);
|
||||
}}
|
||||
disabled={currPage === 1 || isDisabled}
|
||||
className="rounded-md border px-2 py-1 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="rounded-md bg-white border px-2 py-1 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
@@ -91,7 +91,7 @@ export function Pagination(props: PaginationProps) {
|
||||
)}
|
||||
<button
|
||||
disabled={currPage === totalPages || isDisabled}
|
||||
className="rounded-md border px-2 py-1 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="rounded-md bg-white border px-2 py-1 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
onPageChange(currPage + 1);
|
||||
}}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import type { RoadmapAIChatHistoryType } from './RoadmapAIChat';
|
||||
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { BotIcon, User2Icon } from 'lucide-react';
|
||||
|
||||
|
185
src/components/Select.tsx
Normal file
185
src/components/Select.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import * as React from 'react';
|
||||
import { Select as SelectPrimitive } from 'radix-ui';
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/classname';
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: 'sm' | 'default';
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between gap-2 rounded-lg border bg-transparent px-3 py-2 text-sm whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border bg-white shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn('px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-gray-100 focus:text-black data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn(
|
||||
'pointer-events-none -mx-1 my-1 h-px bg-gray-200',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
@@ -24,7 +24,7 @@ export function CreateCourseModal(props: CreateCourseModalProps) {
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const subject = formData.get('subject');
|
||||
|
||||
window.location.href = `/ai/search?term=${subject}&difficulty=beginner&src=topic`;
|
||||
window.location.href = `/ai/course?term=${subject}&difficulty=beginner&src=topic`;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
@@ -278,7 +278,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
return;
|
||||
}
|
||||
}}
|
||||
href={`/ai/search?term=${subject}&difficulty=beginner&src=topic`}
|
||||
href={`/ai/course?term=${subject}&difficulty=beginner&src=topic`}
|
||||
className="flex items-center gap-1 gap-2 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black"
|
||||
>
|
||||
{subject}
|
||||
@@ -289,7 +289,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
|
||||
{roadmapTreeMapping?.subjects?.length === 0 && (
|
||||
<a
|
||||
target="_blank"
|
||||
href={`/ai/search?term=${roadmapTreeMapping?.text}&difficulty=beginner&src=topic`}
|
||||
href={`/ai/course?term=${roadmapTreeMapping?.text}&difficulty=beginner&src=topic`}
|
||||
className="flex items-center gap-1 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black"
|
||||
>
|
||||
{nodeTextParts.slice(1).map((text, index) => {
|
||||
|
@@ -121,7 +121,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
|
||||
const CREATOR_ID_REGEX = new RegExp('@CREATORID:(\\w+)@');
|
||||
|
||||
await readStream(reader, {
|
||||
onStream: (result) => {
|
||||
onStream: async (result) => {
|
||||
if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) {
|
||||
const courseIdMatch = result.match(COURSE_ID_REGEX);
|
||||
const courseSlugMatch = result.match(COURSE_SLUG_REGEX);
|
||||
@@ -166,7 +166,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
|
||||
console.error('Error parsing streamed course content:', e);
|
||||
}
|
||||
},
|
||||
onStreamEnd: (result) => {
|
||||
onStreamEnd: async (result) => {
|
||||
result = result
|
||||
.replace(COURSE_ID_REGEX, '')
|
||||
.replace(COURSE_SLUG_REGEX, '')
|
||||
|
142
src/helper/generate-ai-guide.ts
Normal file
142
src/helper/generate-ai-guide.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { queryClient } from '../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
||||
import { readChatStream } from '../lib/chat';
|
||||
import { markdownToHtmlWithHighlighting } from '../lib/markdown';
|
||||
|
||||
type GuideDetails = {
|
||||
guideId: string;
|
||||
guideSlug: string;
|
||||
creatorId: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
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;
|
||||
onError?: (error: string) => void;
|
||||
src?: string;
|
||||
onHtmlChange?: (html: string) => void;
|
||||
onStreamingChange?: (isStreaming: boolean) => void;
|
||||
onDetailsChange?: (details: GuideDetails) => void;
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
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,
|
||||
} = options;
|
||||
|
||||
onLoadingChange?.(true);
|
||||
onGuideChange?.('');
|
||||
|
||||
try {
|
||||
let response = null;
|
||||
|
||||
if (slug && isForce) {
|
||||
response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-regenerate-ai-guide/${slug}`,
|
||||
{
|
||||
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-guide`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
keyword: term,
|
||||
depth,
|
||||
isForce,
|
||||
customPrompt: prompt,
|
||||
instructions,
|
||||
goal,
|
||||
about,
|
||||
src,
|
||||
}),
|
||||
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) => {
|
||||
onGuideChange?.(message);
|
||||
onHtmlChange?.(await markdownToHtmlWithHighlighting(message));
|
||||
},
|
||||
onMessageEnd: async (message) => {
|
||||
onGuideChange?.(message);
|
||||
onHtmlChange?.(await markdownToHtmlWithHighlighting(message));
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
onStreamingChange?.(false);
|
||||
},
|
||||
onDetails: async (details) => {
|
||||
if (!details?.guideId || !details?.guideSlug) {
|
||||
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);
|
||||
}
|
||||
}
|
19
src/helper/shuffle.ts
Normal file
19
src/helper/shuffle.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function shuffle<T = any>(array: T[]): T[] {
|
||||
let currentIndex = array.length;
|
||||
const result = [...array];
|
||||
|
||||
// While there remain elements to shuffle...
|
||||
while (currentIndex != 0) {
|
||||
// Pick a remaining element...
|
||||
let randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
|
||||
// And swap it with the current element.
|
||||
[result[currentIndex], result[randomIndex]] = [
|
||||
result[randomIndex],
|
||||
result[currentIndex],
|
||||
];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
137
src/hooks/use-chat.ts
Normal file
137
src/hooks/use-chat.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { removeAuthToken } from '../lib/jwt';
|
||||
import { readChatStream } from '../lib/chat';
|
||||
import { markdownToHtmlWithHighlighting } from '../lib/markdown';
|
||||
import { flushSync } from 'react-dom';
|
||||
|
||||
export type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
type UseChatOptions = {
|
||||
endpoint: string;
|
||||
initialMessages?: ChatMessage[];
|
||||
onError?: (error: Error) => void;
|
||||
data?: Record<string, any>;
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
export function useChat(options: UseChatOptions) {
|
||||
const { endpoint, initialMessages, onError, data = {}, onFinish } = options;
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(
|
||||
initialMessages || [],
|
||||
);
|
||||
|
||||
// we use it to show optimistic message
|
||||
// and then replace it with the actual message
|
||||
const [streamedMessageHtml, setStreamedMessageHtml] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [status, setStatus] = useState<
|
||||
'idle' | 'streaming' | 'loading' | 'ready' | 'error'
|
||||
>('idle');
|
||||
|
||||
const sendMessages = useCallback(
|
||||
async (messages: ChatMessage[]) => {
|
||||
try {
|
||||
setStatus('loading');
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ messages, ...data }),
|
||||
signal: abortControllerRef.current?.signal,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setStatus('error');
|
||||
setMessages([...messages].slice(0, messages.length - 1));
|
||||
if (data.status === 401) {
|
||||
removeAuthToken();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
throw new Error(data?.message || 'Something went wrong');
|
||||
}
|
||||
|
||||
const stream = response.body;
|
||||
if (!stream) {
|
||||
setStatus('error');
|
||||
setMessages([...messages].slice(0, messages.length - 1));
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
await readChatStream(stream, {
|
||||
onMessage: async (content) => {
|
||||
const html = await markdownToHtmlWithHighlighting(content);
|
||||
flushSync(() => {
|
||||
setStatus('streaming');
|
||||
setStreamedMessageHtml(html);
|
||||
});
|
||||
},
|
||||
onMessageEnd: async (content) => {
|
||||
const html = await markdownToHtmlWithHighlighting(content);
|
||||
|
||||
flushSync(() => {
|
||||
setStreamedMessageHtml(null);
|
||||
setStatus('ready');
|
||||
setMessages((prevMessages) => {
|
||||
return [
|
||||
...prevMessages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content,
|
||||
html,
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
setStatus('idle');
|
||||
abortControllerRef.current = null;
|
||||
onFinish?.();
|
||||
} catch (error) {
|
||||
if (abortControllerRef.current?.signal.aborted) {
|
||||
// we don't want to show error if the user stops the chat
|
||||
// so we just return
|
||||
return;
|
||||
}
|
||||
|
||||
onError?.(error as Error);
|
||||
setStatus('error');
|
||||
}
|
||||
},
|
||||
[endpoint, onError],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!abortControllerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
setMessages,
|
||||
sendMessages,
|
||||
status,
|
||||
streamedMessageHtml,
|
||||
stop,
|
||||
};
|
||||
}
|
@@ -237,8 +237,7 @@ export function useRoadmapAIChat(options: Options) {
|
||||
});
|
||||
},
|
||||
onDetails: (details) => {
|
||||
const detailsJson = JSON.parse(details);
|
||||
const chatHistoryId = detailsJson?.chatHistoryId;
|
||||
const chatHistoryId = details?.chatHistoryId;
|
||||
if (!chatHistoryId) {
|
||||
return;
|
||||
}
|
||||
|
@@ -27,7 +27,7 @@ export async function readChatStream(
|
||||
}: {
|
||||
onMessage?: (message: string) => Promise<void>;
|
||||
onMessageEnd?: (message: string) => Promise<void>;
|
||||
onDetails?: (details: string) => Promise<void> | void;
|
||||
onDetails?: (details: any) => Promise<void> | void;
|
||||
},
|
||||
) {
|
||||
const reader = stream.getReader();
|
||||
@@ -73,7 +73,7 @@ export async function readChatStream(
|
||||
case CHAT_RESPONSE_PREFIX.message:
|
||||
return { type: 'message', content: JSON.parse(content) };
|
||||
case CHAT_RESPONSE_PREFIX.details:
|
||||
return { type: 'details', content };
|
||||
return { type: 'details', content: JSON.parse(content) };
|
||||
default:
|
||||
throw new Error('Invalid prefix: ' + prefix);
|
||||
}
|
||||
|
@@ -12,6 +12,8 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
|
||||
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
|
||||
>
|
||||
<AITutorLayout activeTab='community' client:load>
|
||||
<AIExploreCourseListing client:load />
|
||||
<div class='mx-auto flex w-full max-w-6xl flex-grow flex-col p-2'>
|
||||
<AIExploreCourseListing client:load />
|
||||
</div>
|
||||
</AITutorLayout>
|
||||
</SkeletonLayout>
|
||||
|
@@ -9,7 +9,7 @@ import { CheckSubscriptionVerification } from '../../components/Billing/CheckSub
|
||||
briefTitle='AI Tutor'
|
||||
description='AI Tutor'
|
||||
keywords={['ai', 'tutor', 'education', 'learning']}
|
||||
canonicalUrl='/ai/search'
|
||||
canonicalUrl='/ai/course'
|
||||
noIndex={true}
|
||||
>
|
||||
<GenerateAICourse client:load />
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import { UserCoursesList } from '../../components/GenerateCourse/UserCoursesList';
|
||||
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
|
||||
import { AITutorLayout } from '../../components/AITutor/AITutorLayout';
|
||||
import { AILibraryLayout } from '../../components/AIGuide/AILibraryLayout';
|
||||
const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
|
||||
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.'
|
||||
>
|
||||
<AITutorLayout activeTab='courses' client:load>
|
||||
<AILibraryLayout activeTab='courses' client:load>
|
||||
<UserCoursesList client:load />
|
||||
</AITutorLayout>
|
||||
</AILibraryLayout>
|
||||
</SkeletonLayout>
|
||||
|
22
src/pages/ai/guide/[slug].astro
Normal file
22
src/pages/ai/guide/[slug].astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import { AIGuide } from '../../../components/GenerateGuide/AIGuide';
|
||||
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
interface Params extends Record<string, string | undefined> {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const { slug } = Astro.params as Params;
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
title='AI Tutor'
|
||||
briefTitle='AI Tutor'
|
||||
description='AI Tutor'
|
||||
keywords={['ai', 'tutor', 'education', 'learning']}
|
||||
canonicalUrl={`/ai/guide/${slug}`}
|
||||
>
|
||||
<AIGuide client:load guideSlug={slug} />
|
||||
</SkeletonLayout>
|
18
src/pages/ai/guide/index.astro
Normal file
18
src/pages/ai/guide/index.astro
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import { AITutorLayout } from '../../../components/AITutor/AITutorLayout';
|
||||
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
|
||||
import { AIGuide } from '../../../components/GenerateGuide/AIGuide';
|
||||
import { GenerateAIGuide } from '../../../components/GenerateGuide/GenerateAIGuide';
|
||||
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}
|
||||
>
|
||||
<AIGuide client:load />
|
||||
</SkeletonLayout>
|
17
src/pages/ai/guides.astro
Normal file
17
src/pages/ai/guides.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import { UserGuidesList } from '../../components/GenerateGuide/UserGuidesList';
|
||||
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='guides' client:load>
|
||||
<UserGuidesList client:load />
|
||||
</AILibraryLayout>
|
||||
</SkeletonLayout>
|
@@ -1,9 +1,8 @@
|
||||
---
|
||||
import { ChevronLeft, PlusCircle, BookOpen, Compass } from 'lucide-react';
|
||||
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
|
||||
import { AICourse } from '../../components/GenerateCourse/AICourse';
|
||||
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
|
||||
import { ContentGenerator } from '../../components/ContentGenerator/ContentGenerator';
|
||||
import { AITutorLayout } from '../../components/AITutor/AITutorLayout';
|
||||
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
|
||||
const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
|
||||
---
|
||||
|
||||
@@ -14,7 +13,7 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
|
||||
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
|
||||
>
|
||||
<AITutorLayout activeTab='new' client:load>
|
||||
<AICourse client:load />
|
||||
<ContentGenerator client:load />
|
||||
<CheckSubscriptionVerification client:load />
|
||||
</AITutorLayout>
|
||||
</SkeletonLayout>
|
||||
|
@@ -12,6 +12,8 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
|
||||
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
|
||||
>
|
||||
<AITutorLayout activeTab='staff-picks' client:load>
|
||||
<AIFeaturedCoursesListing client:load />
|
||||
<div class='mx-auto flex w-full max-w-6xl flex-grow flex-col p-2'>
|
||||
<AIFeaturedCoursesListing client:load />
|
||||
</div>
|
||||
</AITutorLayout>
|
||||
</SkeletonLayout>
|
||||
|
116
src/queries/ai-guide.ts
Normal file
116
src/queries/ai-guide.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { httpGet } from '../lib/query-http';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import {
|
||||
markdownToHtml,
|
||||
markdownToHtmlWithHighlighting,
|
||||
} from '../lib/markdown';
|
||||
|
||||
export interface AIGuideDocument {
|
||||
_id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
slug?: string;
|
||||
keyword: string;
|
||||
depth: string;
|
||||
content: string;
|
||||
tokens: {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
relatedTopics: string[];
|
||||
deepDiveTopics: string[];
|
||||
questions: string[];
|
||||
|
||||
viewCount: number;
|
||||
lastVisitedAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
type GetAIGuideResponse = AIGuideDocument;
|
||||
|
||||
export function getAiGuideOptions(guideSlug?: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['ai-guide', guideSlug],
|
||||
queryFn: async () => {
|
||||
const res = await httpGet<GetAIGuideResponse>(
|
||||
`/v1-get-ai-guide/${guideSlug}`,
|
||||
);
|
||||
|
||||
return {
|
||||
...res,
|
||||
html: await markdownToHtmlWithHighlighting(res.content),
|
||||
};
|
||||
},
|
||||
enabled: !!guideSlug,
|
||||
});
|
||||
}
|
||||
|
||||
type AIGuideSuggestionsResponse = {
|
||||
relatedTopics: string[];
|
||||
deepDiveTopics: string[];
|
||||
questions: string[];
|
||||
};
|
||||
|
||||
export function aiGuideSuggestionsOptions(guideSlug?: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['ai-guide-suggestions', guideSlug],
|
||||
queryFn: () => {
|
||||
return httpGet<AIGuideSuggestionsResponse>(
|
||||
`/v1-ai-guide-suggestions/${guideSlug}`,
|
||||
);
|
||||
},
|
||||
enabled: !!guideSlug && !!isLoggedIn(),
|
||||
});
|
||||
}
|
||||
|
||||
export type ListUserAIGuidesQuery = {
|
||||
perPage?: string;
|
||||
currPage?: string;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
export type ListUserAIGuidesResponse = {
|
||||
data: Omit<
|
||||
AIGuideDocument,
|
||||
'tokens' | 'relatedTopics' | 'deepDiveTopics' | 'questions'
|
||||
>[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
currPage: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export function listUserAIGuidesOptions(
|
||||
params: ListUserAIGuidesQuery = {
|
||||
perPage: '21',
|
||||
currPage: '1',
|
||||
query: '',
|
||||
},
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: ['ai-guides', params],
|
||||
queryFn: async () => {
|
||||
const response = await httpGet<ListUserAIGuidesResponse>(
|
||||
`/v1-list-user-ai-guides`,
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
data: response.data.map((guide) => {
|
||||
const preview = guide.content.slice(0, 500);
|
||||
|
||||
return {
|
||||
...guide,
|
||||
html: markdownToHtml(preview, false),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
enabled: !!isLoggedIn(),
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user