pull/8476/merge
Kamran Ahmed 2 days ago committed by GitHub
commit 9f384f0f43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 128
      src/components/AITutor/AIExploreCourseListing.tsx
  2. 115
      src/components/AITutor/AIFeaturedCoursesListing.tsx
  3. 27
      src/components/AITutor/AILoadingState.tsx
  4. 40
      src/components/AITutor/AITutorHeader.tsx
  5. 42
      src/components/AITutor/AITutorLayout.tsx
  6. 45
      src/components/AITutor/AITutorLimits.tsx
  7. 143
      src/components/AITutor/AITutorSidebar.tsx
  8. 13
      src/components/AITutor/AITutorSidebarProps.tsx
  9. 31
      src/components/AITutor/AITutorTallMessage.tsx
  10. 69
      src/components/AITutor/DifficultyDropdown.tsx
  11. 9
      src/components/Billing/UpgradeAccountModal.tsx
  12. 156
      src/components/GenerateCourse/AICourse.tsx
  13. 22
      src/components/GenerateCourse/AICourseCard.tsx
  14. 65
      src/components/GenerateCourse/AICourseContent.tsx
  15. 68
      src/components/GenerateCourse/AICourseLesson.tsx
  16. 37
      src/components/GenerateCourse/AICourseLessonChat.tsx
  17. 5
      src/components/GenerateCourse/AICourseLimit.tsx
  18. 29
      src/components/GenerateCourse/AICourseOutlineHeader.tsx
  19. 6
      src/components/GenerateCourse/AICourseOutlineView.tsx
  20. 36
      src/components/GenerateCourse/AICourseRoadmapView.tsx
  21. 72
      src/components/GenerateCourse/FineTuneCourse.tsx
  22. 40
      src/components/GenerateCourse/ForkCourseAlert.tsx
  23. 99
      src/components/GenerateCourse/ForkCourseConfirmation.tsx
  24. 3
      src/components/GenerateCourse/GenerateAICourse.tsx
  25. 9
      src/components/GenerateCourse/GetAICourse.tsx
  26. 17
      src/components/GenerateCourse/ModifyCoursePrompt.tsx
  27. 41
      src/components/GenerateCourse/RegenerateLesson.tsx
  28. 30
      src/components/GenerateCourse/RegenerateOutline.tsx
  29. 188
      src/components/GenerateCourse/UserCoursesList.tsx
  30. 36
      src/components/ReactIcons/AITutorLogo.tsx
  31. 17
      src/pages/ai/community.astro
  32. 17
      src/pages/ai/courses.astro
  33. 18
      src/pages/ai/index.astro
  34. 17
      src/pages/ai/staff-picks.astro
  35. 68
      src/queries/ai-course.ts

@ -0,0 +1,128 @@
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 {
listExploreAiCoursesOptions,
type ListExploreAiCoursesQuery,
} from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
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';
export function AIExploreCourseListing() {
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
const [pageState, setPageState] = useState<ListExploreAiCoursesQuery>({
perPage: '21',
currPage: '1',
query: '',
});
const {
data: exploreAiCourses,
isFetching: isExploreAiCoursesLoading,
isRefetching: isExploreAiCoursesRefetching,
} = useQuery(listExploreAiCoursesOptions(pageState), queryClient);
useEffect(() => {
setIsInitialLoading(false);
}, [exploreAiCourses]);
const courses = exploreAiCourses?.data ?? [];
useEffect(() => {
const queryParams = getUrlParams();
setPageState({
...pageState,
currPage: queryParams?.p || '1',
});
}, []);
useEffect(() => {
if (pageState?.currPage !== '1') {
setUrlParams({
p: pageState?.currPage || '1',
});
} else {
deleteUrlParam('p');
}
}, [pageState]);
return (
<>
{showUpgradePopup && (
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<AITutorHeader
title="Explore Courses"
onUpgradeClick={() => setShowUpgradePopup(true)}
>
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
setPageState({
...pageState,
query: value,
currPage: '1',
});
}}
/>
</AITutorHeader>
{(isInitialLoading || isExploreAiCoursesLoading) && (
<AILoadingState
title="Loading courses"
subtitle="This may take a moment..."
/>
)}
{!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}
/>
))}
</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>
)}
{!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';
}}
/>
)}
</>
);
}

@ -0,0 +1,115 @@
import { useQuery } from '@tanstack/react-query';
import {
listFeaturedAiCoursesOptions,
type ListUserAiCoursesQuery,
} from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { useEffect, useState } from 'react';
import { getUrlParams, setUrlParams, deleteUrlParam } from '../../lib/browser';
import { AICourseCard } from '../GenerateCourse/AICourseCard';
import { Pagination } from '../Pagination/Pagination';
import { AITutorHeader } from './AITutorHeader';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { AITutorTallMessage } from './AITutorTallMessage';
import { BookOpen } from 'lucide-react';
import { AILoadingState } from './AILoadingState';
export function AIFeaturedCoursesListing() {
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({
perPage: '21',
currPage: '1',
});
const { data: featuredAiCourses, isFetching: isFeaturedAiCoursesLoading } =
useQuery(listFeaturedAiCoursesOptions(pageState), queryClient);
useEffect(() => {
setIsInitialLoading(false);
}, [featuredAiCourses]);
const courses = featuredAiCourses?.data ?? [];
useEffect(() => {
const queryParams = getUrlParams();
setPageState({
...pageState,
currPage: queryParams?.p || '1',
});
}, []);
useEffect(() => {
if (pageState?.currPage !== '1') {
setUrlParams({
p: pageState?.currPage || '1',
});
} else {
deleteUrlParam('p');
}
}, [pageState]);
return (
<>
{showUpgradePopup && (
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<AITutorHeader
title="Featured Courses"
onUpgradeClick={() => setShowUpgradePopup(true)}
/>
{(isFeaturedAiCoursesLoading || isInitialLoading) && (
<AILoadingState
title="Loading featured courses"
subtitle="This may take a moment..."
/>
)}
{!isFeaturedAiCoursesLoading &&
!isInitialLoading &&
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}
/>
))}
</div>
<Pagination
totalCount={featuredAiCourses?.totalCount || 0}
totalPages={featuredAiCourses?.totalPages || 0}
currPage={Number(featuredAiCourses?.currPage || 1)}
perPage={Number(featuredAiCourses?.perPage || 10)}
onPageChange={(page) => {
setPageState({ ...pageState, currPage: String(page) });
}}
className="rounded-lg border border-gray-200 bg-white p-4"
/>
</div>
)}
{!isFeaturedAiCoursesLoading &&
!isInitialLoading &&
courses.length === 0 && (
<AITutorTallMessage
title="No featured courses"
subtitle="There are no featured courses available at the moment."
icon={BookOpen}
buttonText="Browse all courses"
onButtonClick={() => {
window.location.href = '/ai';
}}
/>
)}
</>
);
}

@ -0,0 +1,27 @@
import { Loader2 } from 'lucide-react';
type AILoadingStateProps = {
title: string;
subtitle?: string;
};
export function AILoadingState(props: AILoadingStateProps) {
const { title, subtitle } = props;
return (
<div className="flex flex-grow w-full flex-col items-center justify-center gap-4 rounded-lg border border-gray-200 bg-white p-8">
<div className="relative">
<Loader2 className="size-12 animate-spin text-gray-300" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="size-4 rounded-full bg-white"></div>
</div>
</div>
<div className="text-center">
<p className="text-lg font-medium text-gray-900">{title}</p>
{subtitle && (
<p className="mt-1 text-sm text-gray-500">{subtitle}</p>
)}
</div>
</div>
);
}

@ -0,0 +1,40 @@
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';
type AITutorHeaderProps = {
title: string;
onUpgradeClick: () => void;
children?: React.ReactNode;
};
export function AITutorHeader(props: AITutorHeaderProps) {
const { title, onUpgradeClick, children } = props;
const { data: limits } = useQuery(getAiCourseLimitOptions(), queryClient);
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
const { used, limit } = limits ?? { used: 0, limit: 0 };
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>
</div>
);
}

@ -0,0 +1,42 @@
import { Menu } from 'lucide-react';
import { useState } from 'react';
import { AITutorSidebar, type AITutorTab } from './AITutorSidebar';
import { RoadmapLogoIcon } from '../ReactIcons/RoadmapLogo';
type AITutorLayoutProps = {
children: React.ReactNode;
activeTab: AITutorTab;
};
export function AITutorLayout(props: AITutorLayoutProps) {
const { children, activeTab } = props;
const [isSidebarFloating, setIsSidebarFloating] = useState(false);
return (
<>
<div className="flex flex-row items-center justify-between border-b border-slate-200 px-4 py-3 lg:hidden">
<a href="/" className="flex flex-row items-center gap-1.5">
<RoadmapLogoIcon className="size-6 text-gray-500" color="black" />
</a>
<button
className="flex flex-row items-center gap-1"
onClick={() => setIsSidebarFloating(!isSidebarFloating)}
>
<Menu className="size-5 text-gray-500" />
</button>
</div>
<div className="flex flex-grow flex-row">
<AITutorSidebar
onClose={() => setIsSidebarFloating(false)}
isFloating={isSidebarFloating}
activeTab={activeTab}
/>
<div className="flex flex-grow flex-col overflow-y-scroll bg-gray-100 p-3 lg:px-4 lg:py-4">
{children}
</div>
</div>
</>
);
}

@ -0,0 +1,45 @@
import { Gift } from 'lucide-react';
import { cn } from '../../lib/classname';
type AITutorLimitsProps = {
used: number;
limit: number;
isPaidUser: boolean;
isPaidUserLoading: boolean;
onUpgradeClick: () => void;
};
export function AITutorLimits(props: AITutorLimitsProps) {
const limitUsedPercentage = Math.round((props.used / props.limit) * 100);
if (props.used <= 0 || props.limit <= 0 || props.isPaidUserLoading) {
return null;
}
return (
<div
className={cn(
'pointer-events-none flex items-center gap-2 opacity-0 transition-opacity',
{
'pointer-events-auto opacity-100': !props.isPaidUser,
},
)}
>
<p className="flex items-center text-sm text-yellow-600">
<span className="max-md:hidden">
{limitUsedPercentage}% of daily limit used{' '}
</span>
<span className="inline md:hidden">
{limitUsedPercentage}% used
</span>
<button
onClick={props.onUpgradeClick}
className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pr-2 pl-1.5 text-xs text-white"
>
<Gift className="size-4" />
Upgrade
</button>
</p>
</div>
);
}

@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import { BookOpen, Compass, Plus, Star, X, Zap } from 'lucide-react';
import { AITutorLogo } from '../ReactIcons/AITutorLogo';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { useIsPaidUser } from '../../queries/billing';
import { isLoggedIn } from '../../lib/jwt';
type AITutorSidebarProps = {
isFloating: boolean;
activeTab: AITutorTab;
onClose: () => void;
};
const sidebarItems = [
{
key: 'new',
label: 'New Course',
href: '/ai',
icon: Plus,
},
{
key: 'courses',
label: 'My Courses',
href: '/ai/courses',
icon: BookOpen,
},
{
key: 'staff-picks',
label: 'Staff Picks',
href: '/ai/staff-picks',
icon: Star,
},
{
key: 'community',
label: 'Community',
href: '/ai/community',
icon: Compass,
},
];
export type AITutorTab = (typeof sidebarItems)[number]['key'];
export function AITutorSidebar(props: AITutorSidebarProps) {
const { activeTab, isFloating, onClose } = props;
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
useEffect(() => {
setIsInitialLoad(false);
}, []);
return (
<>
{isUpgradeModalOpen && (
<UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} />
)}
<aside
className={`w-[255px] shrink-0 border-r border-slate-200 ${
isFloating
? 'fixed top-0 bottom-0 left-0 z-50 block border-r-0 bg-white shadow-xl'
: 'hidden lg:block'
}`}
>
{isFloating && (
<button className="absolute top-3 right-3" onClick={onClose}>
<X
strokeWidth={3}
className="size-3.5 text-gray-400 hover:text-black"
/>
</button>
)}
<div className="flex flex-col items-start justify-center px-6 py-5">
<div className="flex flex-row items-center gap-1">
<AITutorLogo className="size-11 text-gray-500" color="black" />
</div>
<div className="my-3 flex flex-col">
<h2 className="-mb-px text-base font-semibold text-black">
AI Tutor
</h2>
<span className="text-xs text-gray-500">
by{' '}
<a href="/" className="underline-offset-2 hover:underline">
roadmap.sh
</a>
</span>
</div>
<p className="max-w-[150px] text-xs text-gray-500">
Your personalized learning companion for any topic
</p>
</div>
<ul className="space-y-1">
{sidebarItems.map((item) => (
<li key={item.key}>
<a
href={item.href}
className={`font-regular flex w-full items-center border-r-2 px-5 py-2 text-sm transition-all ${
activeTab === item.key
? 'border-r-black bg-gray-100 text-black'
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
}`}
>
<span className="flex grow items-center">
<item.icon className="mr-2 size-4" />
{item.label}
</span>
</a>
</li>
))}
{!isInitialLoad &&
isLoggedIn() &&
!isPaidUser &&
!isPaidUserLoading && (
<li>
<button
onClick={() => {
setIsUpgradeModalOpen(true);
}}
className="mx-4 mt-4 rounded-xl bg-amber-100 p-4 text-left transition-colors hover:bg-amber-200/80"
>
<span className="mb-2 flex items-center gap-2">
<Zap className="size-4 text-amber-600" />
<span className="font-medium text-amber-900">Upgrade</span>
</span>
<span className="mt-1 block text-left text-xs leading-4 text-amber-700">
Get access to all features and benefits of the AI Tutor.
</span>
</button>
</li>
)}
</ul>
</aside>
{isFloating && (
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
)}
</>
);
}

@ -0,0 +1,13 @@
import { Zap } from 'lucide-react';
<li>
<div className="mx-4 mt-4 rounded-lg bg-amber-50 p-3">
<div className="flex items-center gap-2">
<Zap className="size-4 text-amber-600" />
<span className="font-medium text-amber-900">Free Tier</span>
</div>
<p className="mt-1 text-xs text-amber-700">
Upgrade to Pro to unlock unlimited AI tutoring sessions
</p>
</div>
</li>

@ -0,0 +1,31 @@
import { type LucideIcon } from 'lucide-react';
type AITutorTallMessageProps = {
title: string;
subtitle?: string;
icon: LucideIcon;
buttonText?: string;
onButtonClick?: () => void;
};
export function AITutorTallMessage(props: AITutorTallMessageProps) {
const { title, subtitle, icon: Icon, buttonText, onButtonClick } = props;
return (
<div className="flex flex-grow flex-col items-center justify-center rounded-lg border border-gray-200 bg-white p-8">
<Icon className="size-12 text-gray-300" />
<div className="my-4 text-center">
<h2 className="mb-2 text-xl font-semibold">{title}</h2>
{subtitle && <p className="text-base text-gray-600">{subtitle}</p>}
</div>
{buttonText && onButtonClick && (
<button
onClick={onButtonClick}
className="rounded-lg bg-black px-4 py-2 text-sm text-white hover:opacity-80"
>
{buttonText}
</button>
)}
</div>
);
}

@ -0,0 +1,69 @@
import { ChevronDown } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { cn } from '../../lib/classname';
import {
difficultyLevels,
type DifficultyLevel,
} from '../GenerateCourse/AICourse';
type DifficultyDropdownProps = {
value: DifficultyLevel;
onChange: (value: DifficultyLevel) => void;
};
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>
);
}

@ -234,7 +234,14 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
)}
</p>
)}
<p className="text-2xl font-bold text-black sm:text-3xl">
<p
className={cn(
'text-2xl font-bold text-black sm:text-3xl',
{
'mt-0 md:mt-6': !isYearly,
},
)}
>
${plan.amount}{' '}
<span className="text-xs font-normal text-gray-500 sm:text-sm">
/ {isYearly ? 'year' : 'month'}

@ -1,16 +1,16 @@
import { SearchIcon, WandIcon } from 'lucide-react';
import { WandIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { cn } from '../../lib/classname';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { UserCoursesList } from './UserCoursesList';
import { FineTuneCourse } from './FineTuneCourse';
import { DifficultyDropdown } from '../AITutor/DifficultyDropdown';
import {
clearFineTuneData,
getCourseFineTuneData,
getLastSessionId,
storeFineTuneData,
} from '../../lib/ai';
import { cn } from '../../lib/classname';
export const difficultyLevels = [
'beginner',
@ -72,86 +72,63 @@ export function AICourse(props: AICourseProps) {
}
return (
<section className="flex grow flex-col bg-gray-100">
<div className="container mx-auto flex max-w-3xl flex-col py-24 max-sm:py-4">
<h1 className="mb-2.5 text-center text-4xl font-bold max-sm:mb-2 max-sm:text-left max-sm:text-xl">
Learn anything with AI
</h1>
<p className="mb-6 text-center text-lg text-gray-600 max-sm:hidden max-sm:text-left max-sm:text-sm">
Enter a topic below to generate a personalized course for it
</p>
<div className="rounded-lg border border-gray-200 bg-white p-6 max-sm:p-4">
<form
className="flex flex-col gap-5"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<div className="flex flex-col">
<div className="mx-auto flex w-full max-w-3xl flex-grow flex-col pt-4 md:justify-center md:pt-10 lg:pt-0">
<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="mb-3 text-balance text-center text-lg text-gray-600 max-md:text-left max-md:text-sm lg:mb-6">
Enter a topic below to generate a personalized course for it
</p>
<div className="rounded-lg border border-gray-300 bg-white">
<form
className="flex flex-col"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<input
id="keyword"
type="text"
value={keyword}
autoFocus={true}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g. JavaScript Promises, React Hooks, Go Routines etc"
className="w-full rounded-md border-none bg-transparent px-4 pt-4 pb-8 text-gray-900 focus:outline-hidden max-sm:placeholder:text-base"
maxLength={50}
/>
<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">
<DifficultyDropdown
value={difficulty}
onChange={setDifficulty}
/>
</div>
<label
htmlFor="keyword"
className="mb-2.5 text-sm font-medium text-gray-700"
htmlFor="fine-tune-checkbox"
className="flex cursor-pointer flex-row items-center gap-1 rounded-full bg-gray-100 px-4 py-1 text-sm text-gray-700 hover:bg-gray-200 hover:text-gray-700"
>
Course Topic
</label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<SearchIcon size={18} />
</div>
<input
id="keyword"
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., Algebra, JavaScript, Photography"
className="w-full rounded-md border border-gray-300 bg-white p-3 pl-10 text-gray-900 focus:outline-hidden focus:ring-1 focus:ring-gray-500 max-sm:placeholder:text-base"
maxLength={50}
type="checkbox"
checked={hasFineTuneData}
onChange={() => setHasFineTuneData(!hasFineTuneData)}
className="mr-1"
id="fine-tune-checkbox"
/>
</div>
</div>
<div className="flex flex-col">
<label className="mb-2.5 text-sm font-medium text-gray-700">
Difficulty Level
Explain more
<span className="hidden md:inline"> for a better course</span>
</label>
<div className="flex gap-2 max-sm:flex-col max-sm:gap-1">
{difficultyLevels.map((level) => (
<button
key={level}
type="button"
onClick={() => setDifficulty(level)}
className={cn(
'rounded-md border px-4 py-2 capitalize max-sm:text-sm',
difficulty === level
? 'border-gray-800 bg-gray-800 text-white'
: 'border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-200',
)}
>
{level}
</button>
))}
</div>
</div>
<FineTuneCourse
hasFineTuneData={hasFineTuneData}
setHasFineTuneData={setHasFineTuneData}
about={about}
goal={goal}
customInstructions={customInstructions}
setAbout={setAbout}
setGoal={setGoal}
setCustomInstructions={setCustomInstructions}
/>
<button
type="submit"
disabled={!keyword.trim()}
className={cn(
'mt-2 flex items-center justify-center rounded-md px-4 py-2 font-medium text-white transition-colors max-sm:text-sm',
'hidden items-center justify-center rounded-full px-4 py-1 text-sm text-white transition-colors md:flex',
!keyword.trim()
? 'cursor-not-allowed bg-gray-400'
: 'bg-black hover:bg-gray-800',
@ -160,13 +137,34 @@ export function AICourse(props: AICourseProps) {
<WandIcon size={18} className="mr-2" />
Generate Course
</button>
</form>
</div>
<div className="mt-8 min-h-[200px]">
<UserCoursesList />
</div>
</div>
<FineTuneCourse
hasFineTuneData={hasFineTuneData}
setHasFineTuneData={setHasFineTuneData}
about={about}
goal={goal}
customInstructions={customInstructions}
setAbout={setAbout}
setGoal={setGoal}
setCustomInstructions={setCustomInstructions}
/>
<button
type="submit"
disabled={!keyword.trim()}
className={cn(
'mx-4 mb-3 flex items-center justify-center rounded-full px-4 py-1 text-sm text-white transition-colors md:hidden',
!keyword.trim()
? 'cursor-not-allowed bg-gray-400'
: 'bg-black hover:bg-gray-800',
)}
>
<WandIcon size={18} className="mr-2" />
Generate Course
</button>
</form>
</div>
</section>
</div>
);
}

@ -5,18 +5,12 @@ import { AICourseActions } from './AICourseActions';
type AICourseCardProps = {
course: AICourseWithLessonCount;
showActions?: boolean;
showProgress?: boolean;
};
export function AICourseCard(props: AICourseCardProps) {
const { course } = props;
// Format date if available
const formattedDate = course.createdAt
? new Date(course.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
: null;
const { course, showActions = true, showProgress = true } = props;
// Map difficulty to color
const difficultyColor =
@ -33,10 +27,10 @@ export function AICourseCard(props: AICourseCardProps) {
totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0;
return (
<div className="relative">
<div className="relative flex flex-grow flex-col">
<a
href={`/ai/${course.slug}`}
className="hover:border-gray-3 00 group relative flex 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="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"
>
<div className="flex items-center justify-between">
<span
@ -56,7 +50,7 @@ export function AICourseCard(props: AICourseCardProps) {
<span>{totalTopics} lessons</span>
</div>
{totalTopics > 0 && (
{showProgress && totalTopics > 0 && (
<div className="flex items-center">
<div className="mr-2 h-1.5 w-16 overflow-hidden rounded-full bg-gray-200">
<div
@ -72,8 +66,8 @@ export function AICourseCard(props: AICourseCardProps) {
</div>
</a>
{course.slug && (
<div className="absolute right-2 top-2">
{showActions && course.slug && (
<div className="absolute top-2 right-2">
<AICourseActions courseSlug={course.slug} />
</div>
)}

@ -5,8 +5,9 @@ import {
CircleOff,
Menu,
X,
Map, MessageCircleOffIcon,
MessageCircleIcon
Map,
MessageCircleOffIcon,
MessageCircleIcon,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { type AiCourse } from '../../lib/ai';
@ -21,6 +22,9 @@ import { AILimitsPopup } from './AILimitsPopup';
import { AICourseOutlineView } from './AICourseOutlineView';
import { AICourseRoadmapView } from './AICourseRoadmapView';
import { AICourseFooter } from './AICourseFooter';
import { ForkCourseAlert } from './ForkCourseAlert';
import { ForkCourseConfirmation } from './ForkCourseConfirmation';
import { useAuth } from '../../hooks/use-auth';
type AICourseContentProps = {
courseSlug?: string;
@ -28,12 +32,20 @@ type AICourseContentProps = {
isLoading: boolean;
error?: string;
onRegenerateOutline: (prompt?: string) => void;
creatorId?: string;
};
export type AICourseViewMode = 'module' | 'outline' | 'roadmap';
export function AICourseContent(props: AICourseContentProps) {
const { course, courseSlug, isLoading, error, onRegenerateOutline } = props;
const {
course,
courseSlug,
isLoading,
error,
onRegenerateOutline,
creatorId,
} = props;
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
@ -43,8 +55,10 @@ export function AICourseContent(props: AICourseContentProps) {
const [activeLessonIndex, setActiveLessonIndex] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [viewMode, setViewMode] = useState<AICourseViewMode>('outline');
const [isForkingCourse, setIsForkingCourse] = useState(false);
const { isPaidUser } = useIsPaidUser();
const currentUser = useAuth();
const aiCourseProgress = course.done || [];
@ -202,7 +216,7 @@ export function AICourseContent(props: AICourseContentProps) {
<div className="my-5">
<a
href="/ai"
className="rounded-md bg-black px-6 py-2 text-sm font-medium text-white hover:bg-opacity-80"
className="hover:bg-opacity-80 rounded-md bg-black px-6 py-2 text-sm font-medium text-white"
>
Create a course with AI
</a>
@ -214,6 +228,7 @@ export function AICourseContent(props: AICourseContentProps) {
}
const isViewingLesson = viewMode === 'module';
const isForkable = !!currentUser?.id && currentUser.id !== creatorId;
return (
<section className="flex h-screen grow flex-col overflow-hidden bg-gray-50">
@ -233,7 +248,10 @@ export function AICourseContent(props: AICourseContentProps) {
aria-label="Back to generator"
>
<ChevronLeft className="size-4" strokeWidth={2.5} />
Back {isViewingLesson ? 'to Outline' : 'to AI Tutor'}
Back{' '}
<span className="hidden lg:inline">
{isViewingLesson ? 'to Outline' : 'to AI Tutor'}
</span>
</a>
<div className="flex items-center gap-2">
<div className="flex flex-row lg:hidden">
@ -272,7 +290,7 @@ export function AICourseContent(props: AICourseContentProps) {
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 max-lg:py-4 lg:h-[80px]">
<div className="flex items-center">
<div className="flex flex-col">
<h1 className="text-balance text-xl font-bold leading-tight! text-gray-900 max-lg:mb-0.5 max-lg:text-lg">
<h1 className="text-xl leading-tight! font-bold text-balance text-gray-900 max-lg:mb-0.5 max-lg:text-lg">
{course.title || 'Loading Course...'}
</h1>
<div className="mt-1 flex flex-row items-center gap-2 text-sm text-gray-600 max-lg:text-xs">
@ -342,7 +360,7 @@ export function AICourseContent(props: AICourseContentProps) {
width: `${finishedPercentage}%`,
}}
className={cn(
'absolute bottom-0 left-0 top-0',
'absolute top-0 bottom-0 left-0',
'bg-gray-200/50',
)}
></span>
@ -420,9 +438,30 @@ export function AICourseContent(props: AICourseContentProps) {
)}
key={`${courseSlug}-${viewMode}`}
>
{isForkable &&
courseSlug &&
(viewMode === 'outline' || viewMode === 'roadmap') && (
<ForkCourseAlert
creatorId={creatorId}
onForkCourse={() => {
setIsForkingCourse(true);
}}
/>
)}
{isForkingCourse && (
<ForkCourseConfirmation
onClose={() => {
setIsForkingCourse(false);
}}
courseSlug={courseSlug!}
/>
)}
{viewMode === 'module' && (
<AICourseLesson
courseSlug={courseSlug!}
creatorId={creatorId}
progress={aiCourseProgress}
activeModuleIndex={activeModuleIndex}
totalModules={totalModules}
@ -436,6 +475,10 @@ export function AICourseContent(props: AICourseContentProps) {
onUpgrade={() => setShowUpgradeModal(true)}
isAIChatsOpen={isAIChatsOpen}
setIsAIChatsOpen={setIsAIChatsOpen}
isForkable={isForkable}
onForkCourse={() => {
setIsForkingCourse(true);
}}
/>
)}
@ -450,6 +493,10 @@ export function AICourseContent(props: AICourseContentProps) {
setViewMode={setViewMode}
setExpandedModules={setExpandedModules}
viewMode={viewMode}
isForkable={isForkable}
onForkCourse={() => {
setIsForkingCourse(true);
}}
/>
)}
@ -466,6 +513,10 @@ export function AICourseContent(props: AICourseContentProps) {
setExpandedModules={setExpandedModules}
onUpgradeClick={() => setShowUpgradeModal(true)}
viewMode={viewMode}
isForkable={isForkable}
onForkCourse={() => {
setIsForkingCourse(true);
}}
/>
)}

@ -3,6 +3,7 @@ import {
CheckIcon,
ChevronLeft,
ChevronRight,
GitForkIcon,
Loader2Icon,
LockIcon,
MessageCircleIcon,
@ -39,6 +40,7 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from './Resizeable';
import { showLoginPopup } from '../../lib/popup';
function getQuestionsFromResult(result: string) {
const matchedQuestions = result.match(
@ -55,6 +57,7 @@ function getQuestionsFromResult(result: string) {
type AICourseLessonProps = {
courseSlug: string;
progress: string[];
creatorId?: string;
activeModuleIndex: number;
totalModules: number;
@ -70,12 +73,16 @@ type AICourseLessonProps = {
isAIChatsOpen: boolean;
setIsAIChatsOpen: (isOpen: boolean) => void;
isForkable: boolean;
onForkCourse: () => void;
};
export function AICourseLesson(props: AICourseLessonProps) {
const {
courseSlug,
progress = [],
creatorId,
activeModuleIndex,
totalModules,
@ -91,6 +98,9 @@ export function AICourseLesson(props: AICourseLessonProps) {
isAIChatsOpen,
setIsAIChatsOpen,
isForkable,
onForkCourse,
} = props;
const [isLoading, setIsLoading] = useState(true);
@ -108,8 +118,7 @@ export function AICourseLesson(props: AICourseLessonProps) {
>([
{
role: 'assistant',
content:
'Hey, I am your AI instructor. How can I help you today? 🤖',
content: 'Hey, I am your AI instructor. How can I help you today? 🤖',
isDefault: true,
},
]);
@ -205,7 +214,7 @@ export function AICourseLesson(props: AICourseLessonProps) {
const questions = getQuestionsFromResult(result);
setDefaultQuestions(questions);
const newResult = result.replace(
/=START_QUESTIONS=.*?=END_QUESTIONS=/,
'',
@ -284,7 +293,7 @@ export function AICourseLesson(props: AICourseLessonProps) {
<div className="relative mx-auto max-w-5xl">
<div className="bg-white p-8 pb-0 max-lg:px-4 max-lg:pt-3">
{(isGenerating || isLoading) && (
<div className="absolute right-6 top-6 flex items-center justify-center">
<div className="absolute top-6 right-6 flex items-center justify-center">
<Loader2Icon
size={18}
strokeWidth={3}
@ -293,13 +302,13 @@ export function AICourseLesson(props: AICourseLessonProps) {
</div>
)}
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex justify-between max-sm:flex-col-reverse">
<div className="text-sm text-gray-500">
Lesson {activeLessonIndex + 1} of {totalLessons}
</div>
{!isGenerating && !isLoading && (
<div className="absolute top-2 right-2 lg:right-6 lg:top-6 flex items-center justify-between gap-2">
<div className="top-2 right-2 mb-3 flex items-center gap-2 max-sm:justify-end md:absolute lg:top-6 lg:right-6">
<button
onClick={() => setIsAIChatsOpen(!isAIChatsOpen)}
className="rounded-full p-1 text-gray-400 hover:text-black max-lg:hidden"
@ -315,16 +324,40 @@ export function AICourseLesson(props: AICourseLessonProps) {
onRegenerateLesson={(prompt) => {
generateAiCourseContent(true, prompt);
}}
isForkable={isForkable}
onForkCourse={onForkCourse}
/>
{isForkable && (
<button
onClick={onForkCourse}
className="flex items-center gap-1.5 rounded-full border bg-gray-100 py-1 pr-4 pl-3 text-sm text-black hover:bg-gray-200 disabled:opacity-50 max-lg:text-xs"
>
<GitForkIcon className="size-3.5" />
Fork Course
</button>
)}
<button
disabled={isLoading || isTogglingDone}
className={cn(
'flex items-center gap-1.5 rounded-full bg-black py-1 pl-2 pr-3 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs',
'flex items-center gap-1.5 rounded-full bg-black py-1 pr-3 pl-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs',
isLessonDone
? 'bg-red-500 hover:bg-red-600'
: 'bg-green-500 hover:bg-green-600',
)}
onClick={() => toggleDone()}
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (isForkable) {
onForkCourse();
return;
}
toggleDone();
}}
>
{isTogglingDone ? (
<>
@ -355,13 +388,13 @@ export function AICourseLesson(props: AICourseLessonProps) {
)}
</div>
<h1 className="mb-6 text-balance text-3xl font-semibold max-lg:mb-3 max-lg:text-xl">
<h1 className="mb-6 text-3xl font-semibold text-balance max-lg:mb-3 max-lg:text-xl">
{currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
</h1>
{!error && isLoggedIn() && (
<div
className="course-content prose prose-lg mt-8 max-w-full text-black 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:mt-4 max-lg:text-base 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"
className="course-content 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"
dangerouslySetInnerHTML={{ __html: lessonHtml }}
/>
)}
@ -402,10 +435,18 @@ export function AICourseLesson(props: AICourseLessonProps) {
{!isLoggedIn() && (
<div className="mt-8 flex min-h-[152px] flex-col items-center justify-center gap-3 rounded-lg border border-gray-200 p-8">
<LockIcon className="size-7 stroke-2 text-gray-400/90" />
<LockIcon className="size-10 stroke-2 text-gray-400/90" />
<p className="text-sm text-gray-500">
Please login to generate course content
</p>
<button
onClick={() => {
showLoginPopup();
}}
className="rounded-full bg-black px-4 py-1 text-sm text-white hover:bg-gray-800"
>
Login to Continue
</button>
</div>
)}
@ -436,6 +477,11 @@ export function AICourseLesson(props: AICourseLessonProps) {
<div>
<button
onClick={() => {
if (!isLoggedIn()) {
onGoToNextLesson();
return;
}
if (!isLessonDone) {
toggleDone(undefined, {
onSuccess: () => {

@ -34,6 +34,7 @@ import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { ResizablePanel } from './Resizeable';
import { Spinner } from '../ReactIcons/Spinner';
import { showLoginPopup } from '../../lib/popup';
export type AllowedAIChatRole = 'user' | 'assistant';
export type AIChatHistoryType = {
@ -221,23 +222,26 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) {
minSize={20}
id="course-chat-content"
order={2}
className="relative h-full max-lg:fixed max-lg:inset-0 max-lg:data-[chat-state=open]:flex max-lg:data-[chat-state=closed]:hidden"
className="relative h-full max-lg:fixed! max-lg:inset-0! max-lg:data-[chat-state=closed]:hidden max-lg:data-[chat-state=open]:flex"
data-chat-state={isAIChatsOpen ? 'open' : 'closed'}
>
<div
className="absolute inset-y-0 right-0 z-20 flex w-full flex-col overflow-hidden bg-white data-[state=open]:flex data-[state=closed]:hidden"
className="absolute inset-y-0 right-0 z-20 flex w-full flex-col overflow-hidden bg-white data-[state=closed]:hidden data-[state=open]:flex"
data-state={isAIChatsOpen ? 'open' : 'closed'}
>
<button
onClick={onClose}
className="absolute right-2 top-2 z-20 hidden rounded-full p-1 text-gray-400 hover:text-black max-lg:block"
className="absolute top-2 right-2 z-20 hidden rounded-full p-1 text-gray-400 hover:text-black max-lg:block"
>
<XIcon className="size-4 stroke-[2.5]" />
</button>
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm">
<h4 className="flex items-center gap-2 text-base font-medium">
<Bot className="size-5 shrink-0 text-black relative -top-[1px]" strokeWidth={2.5} />
<Bot
className="relative -top-[1px] size-5 shrink-0 text-black"
strokeWidth={2.5}
/>
AI Instructor
</h4>
<button
@ -278,7 +282,7 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) {
/>
{chat.isDefault && defaultQuestions?.length > 1 && (
<div className="mb-1 mt-0.5">
<div className="mt-0.5 mb-1">
<p className="mb-2 text-xs font-normal text-gray-500">
Some questions you might have about this lesson.
</p>
@ -321,8 +325,8 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) {
className="relative flex items-start border-t border-gray-200 text-sm"
onSubmit={handleChatSubmit}
>
{isLimitExceeded && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white">
{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}
@ -343,6 +347,23 @@ export function AICourseLessonChat(props: AICourseLessonChatProps) {
)}
</div>
)}
{!isLoggedIn() && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
<LockIcon
className="size-4 cursor-not-allowed"
strokeWidth={2.5}
/>
<p className="cursor-not-allowed">Please login to continue</p>
<button
onClick={() => {
showLoginPopup();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Login to Chat
</button>
</div>
)}
<TextareaAutosize
className={cn(
'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-hidden',
@ -442,7 +463,7 @@ function CapabilityCard({
>
<div className="flex items-center gap-2">
{icon}
<span className="text-[13px] font-medium leading-none text-black">
<span className="text-[13px] leading-none font-medium text-black">
{title}
</span>
</div>

@ -4,6 +4,7 @@ import { getPercentage } from '../../lib/number';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { billingDetailsOptions } from '../../queries/billing';
import { queryClient } from '../../stores/query-client';
import { isLoggedIn } from '../../lib/jwt';
type AICourseLimitProps = {
onUpgrade: () => void;
@ -21,6 +22,10 @@ export function AICourseLimit(props: AICourseLimitProps) {
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
if (!isLoggedIn()) {
return null;
}
if (isLoading || !limits || isBillingDetailsLoading || !userBillingDetails) {
return (
<div className="hidden h-[38px] w-[208.09px] animate-pulse rounded-lg border border-gray-200 bg-gray-200 lg:block"></div>

@ -10,11 +10,20 @@ type AICourseOutlineHeaderProps = {
onRegenerateOutline: (prompt?: string) => void;
viewMode: AICourseViewMode;
setViewMode: (mode: AICourseViewMode) => void;
isForkable: boolean;
onForkCourse: () => void;
};
export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
const { course, isLoading, onRegenerateOutline, viewMode, setViewMode } =
props;
const {
course,
isLoading,
onRegenerateOutline,
viewMode,
setViewMode,
isForkable,
onForkCourse,
} = props;
return (
<div
@ -24,18 +33,22 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
)}
>
<div className="max-lg:hidden">
<h2 className="mb-1 text-balance text-2xl font-bold max-lg:text-lg max-lg:leading-tight">
<h2 className="mb-1 text-2xl font-bold text-balance max-lg:text-lg max-lg:leading-tight">
{course.title || 'Loading course ..'}
</h2>
<p className="text-sm capitalize text-gray-500">
<p className="text-sm text-gray-500 capitalize">
{course.title ? course.difficulty : 'Please wait ..'}
</p>
</div>
<div className="absolute right-3 top-3 flex gap-2 max-lg:relative max-lg:right-0 max-lg:top-0 max-lg:w-full max-lg:flex-row-reverse max-lg:justify-between">
<div className="absolute top-3 right-3 flex gap-2 max-lg:relative max-lg:top-0 max-lg:right-0 max-lg:w-full max-lg:flex-row-reverse max-lg:justify-between">
{!isLoading && (
<>
<RegenerateOutline onRegenerateOutline={onRegenerateOutline} />
<RegenerateOutline
onRegenerateOutline={onRegenerateOutline}
isForkable={isForkable}
onForkCourse={onForkCourse}
/>
<div className="mr-1 flex rounded-lg border border-gray-200 bg-white p-0.5">
<button
onClick={() => setViewMode('outline')}
@ -55,7 +68,9 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
<span>Outline</span>
</button>
<button
onClick={() => setViewMode('roadmap')}
onClick={() => {
setViewMode('roadmap');
}}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors',
viewMode === 'roadmap'

@ -17,6 +17,8 @@ type AICourseOutlineViewProps = {
setViewMode: (mode: AICourseViewMode) => void;
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>;
viewMode: AICourseViewMode;
isForkable: boolean;
onForkCourse: () => void;
};
export function AICourseOutlineView(props: AICourseOutlineViewProps) {
@ -30,6 +32,8 @@ export function AICourseOutlineView(props: AICourseOutlineViewProps) {
setViewMode,
setExpandedModules,
viewMode,
isForkable,
onForkCourse,
} = props;
const aiCourseProgress = course.done || [];
@ -42,6 +46,8 @@ export function AICourseOutlineView(props: AICourseOutlineViewProps) {
onRegenerateOutline={onRegenerateOutline}
viewMode={viewMode}
setViewMode={setViewMode}
isForkable={isForkable}
onForkCourse={onForkCourse}
/>
{course.title ? (
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4">

@ -17,13 +17,15 @@ import {
} from 'react';
import type { AICourseViewMode } from './AICourseContent';
import { replaceChildren } from '../../lib/dom';
import { Frown, Loader2Icon } from 'lucide-react';
import { Frown, Loader2Icon, LockIcon } from 'lucide-react';
import { renderTopicProgress } from '../../lib/resource-progress';
import { queryClient } from '../../stores/query-client';
import { useQuery } from '@tanstack/react-query';
import { billingDetailsOptions } from '../../queries/billing';
import { AICourseOutlineHeader } from './AICourseOutlineHeader';
import type { AiCourse } from '../../lib/ai';
import { showLoginPopup } from '../../lib/popup';
import { isLoggedIn } from '../../lib/jwt';
export type AICourseRoadmapViewProps = {
done: string[];
@ -37,6 +39,8 @@ export type AICourseRoadmapViewProps = {
onUpgradeClick: () => void;
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>;
viewMode: AICourseViewMode;
isForkable: boolean;
onForkCourse: () => void;
};
export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
@ -52,6 +56,8 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
setExpandedModules,
onUpgradeClick,
viewMode,
isForkable,
onForkCourse,
} = props;
const containerEl = useRef<HTMLDivElement>(null);
@ -66,6 +72,11 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
const isPaidUser = userBillingDetails?.status === 'active';
const generateAICourseRoadmap = async (courseSlug: string) => {
if (!isLoggedIn()) {
setIsGenerating(false);
return;
}
try {
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course-roadmap/${courseSlug}`,
@ -216,6 +227,8 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
}}
viewMode={viewMode}
setViewMode={setViewMode}
isForkable={isForkable}
onForkCourse={onForkCourse}
/>
{isLoading && (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
@ -223,10 +236,27 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
</div>
)}
{error && !isGenerating && (
{!isLoggedIn() && (
<div className="absolute inset-0 flex h-full w-full flex-col items-center justify-center gap-2">
<LockIcon className="size-10 stroke-2 text-gray-400/90" />
<p className="text-sm text-gray-500">
Please login to generate course content
</p>
<button
onClick={() => {
showLoginPopup();
}}
className="rounded-full bg-black px-4 py-1 text-sm text-white hover:bg-gray-800"
>
Login to Continue
</button>
</div>
)}
{error && !isGenerating && !isLoggedIn() && (
<div className="absolute inset-0 flex h-full w-full flex-col items-center justify-center">
<Frown className="size-20 text-red-500" />
<p className="mx-auto mt-5 max-w-[250px] text-balance text-center text-base text-red-500">
<p className="mx-auto mt-5 max-w-[250px] text-center text-base text-balance text-red-500">
{error || 'Something went wrong'}
</p>

@ -1,6 +1,3 @@
import { useState } from 'react';
import { cn } from '../../lib/classname';
type QuestionProps = {
label: string;
placeholder: string;
@ -52,52 +49,31 @@ export function FineTuneCourse(props: FineTuneCourseProps) {
setHasFineTuneData,
} = props;
return (
<div className="flex flex-col overflow-hidden rounded-lg border border-gray-200 transition-all">
<label
className={cn(
'group flex cursor-pointer select-none flex-row items-center gap-2.5 px-4 py-3 text-left text-gray-500 transition-colors hover:bg-gray-100 focus:outline-hidden',
hasFineTuneData && 'bg-gray-100',
)}
>
<input
id="fine-tune-checkbox"
type="checkbox"
className="h-4 w-4 group-hover:fill-current"
checked={hasFineTuneData}
onChange={() => {
setHasFineTuneData(!hasFineTuneData);
}}
/>
Tell us more to tailor the course (optional){' '}
<span className="ml-auto rounded-md bg-gray-400 px-2 py-0.5 text-xs text-white">
recommended
</span>
</label>
if (!hasFineTuneData) {
return null;
}
{hasFineTuneData && (
<div className="mt-0 flex flex-col">
<Question
label="Tell us about your self"
placeholder="e.g. I am a frontend developer and have good knowledge of HTML, CSS, and JavaScript."
value={about}
onChange={setAbout}
autoFocus={true}
/>
<Question
label="What is your goal with this course?"
placeholder="e.g. I want to be able to build Node.js APIs with Express.js and MongoDB."
value={goal}
onChange={setGoal}
/>
<Question
label="Custom Instructions (Optional)"
placeholder="Give additional instructions to the AI as if you were giving them to a friend."
value={customInstructions}
onChange={setCustomInstructions}
/>
</div>
)}
return (
<div className="mt-0 flex flex-col">
<Question
label="Tell us about your self"
placeholder="e.g. I am a frontend developer and have good knowledge of HTML, CSS, and JavaScript."
value={about}
onChange={setAbout}
autoFocus={true}
/>
<Question
label="What is your goal with this course?"
placeholder="e.g. I want to be able to build Node.js APIs with Express.js and MongoDB."
value={goal}
onChange={setGoal}
/>
<Question
label="Custom Instructions (Optional)"
placeholder="Give additional instructions to the AI as if you were giving them to a friend."
value={customInstructions}
onChange={setCustomInstructions}
/>
</div>
);
}

@ -0,0 +1,40 @@
import { GitForkIcon } from 'lucide-react';
import { getUser } from '../../lib/jwt';
import { cn } from '../../lib/classname';
type ForkCourseAlertProps = {
className?: string;
creatorId?: string;
onForkCourse: () => void;
};
export function ForkCourseAlert(props: ForkCourseAlertProps) {
const { creatorId, onForkCourse, className = '' } = props;
const currentUser = getUser();
if (!currentUser || !creatorId || currentUser?.id === creatorId) {
return null;
}
return (
<div
className={cn(
'mx-auto mb-3.5 flex max-w-5xl items-center justify-between gap-2 rounded-lg bg-yellow-200 p-3 text-black lg:-mt-2.5',
className,
)}
>
<p className="text-sm text-balance">
Fork the course to track progress and make changes to the course.
</p>
<button
className="flex shrink-0 items-center gap-2 rounded-md hover:bg-yellow-500 bg-yellow-400 px-3 py-1.5 text-sm text-black"
onClick={onForkCourse}
>
<GitForkIcon className="size-3.5" />
Fork Course
</button>
</div>
);
}

@ -0,0 +1,99 @@
import { Copy, GitForkIcon, Loader2Icon } from 'lucide-react';
import { Modal } from '../Modal';
import type { AICourseDocument } from '../../queries/ai-course';
import { useMutation } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { useState } from 'react';
type ForkAICourseParams = {
aiCourseSlug: string;
};
type ForkAICourseBody = {};
type ForkAICourseQuery = {};
type ForkAICourseResponse = AICourseDocument;
type ForkCourseConfirmationProps = {
onClose: () => void;
courseSlug: string;
};
export function ForkCourseConfirmation(props: ForkCourseConfirmationProps) {
const { onClose, courseSlug } = props;
const toast = useToast();
const [isPending, setIsPending] = useState(false);
const { mutate: forkCourse } = useMutation(
{
mutationFn: async () => {
setIsPending(true);
return httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-fork-ai-course/${courseSlug}`,
{},
);
},
onSuccess(data) {
window.location.href = `/ai/${data.slug}`;
},
onError(error) {
toast.error(error?.message || 'Failed to fork course');
setIsPending(false);
},
},
queryClient,
);
return (
<Modal
onClose={isPending ? () => {} : onClose}
wrapperClassName="h-auto items-start max-w-md w-full"
overlayClassName="items-start md:items-center"
>
<div className="relative flex flex-col items-center p-8">
<div className="p-4">
<Copy className="size-12 text-gray-300" strokeWidth={1.5} />
</div>
<div className="mt-6 text-center">
<h2 className="text-2xl font-bold text-gray-900">Fork Course</h2>
<p className="mt-3 text-center leading-relaxed text-balance text-gray-600">
Create a copy of this course to track your progress and make changes
to suit your learning style.
</p>
</div>
<div className="mt-8 grid w-full grid-cols-2 gap-3">
<button
disabled={isPending}
onClick={onClose}
className="flex items-center justify-center gap-2 rounded-lg border border-gray-200 px-4 py-2.5 font-medium text-gray-700 transition-all hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
disabled={isPending}
className="flex hover:opacity-80 items-center justify-center gap-2 rounded-lg bg-black px-4 py-2.5 font-medium text-white transition-all hover:bg-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => forkCourse()}
>
{isPending ? (
<>
<Loader2Icon className="size-4 animate-spin" />
<span>Forking...</span>
</>
) : (
<>
<GitForkIcon className="size-4" />
<span>Fork Course</span>
</>
)}
</button>
</div>
</div>
</Modal>
);
}

@ -7,6 +7,7 @@ import { generateCourse } from '../../helper/generate-ai-course';
import { useQuery } from '@tanstack/react-query';
import { getAiCourseOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { useAuth } from '../../hooks/use-auth';
type GenerateAICourseProps = {};
@ -20,6 +21,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const currentUser = useAuth();
const [courseId, setCourseId] = useState('');
const [courseSlug, setCourseSlug] = useState('');
@ -150,6 +152,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
return (
<AICourseContent
courseSlug={courseSlug}
creatorId={currentUser?.id}
course={course}
isLoading={isLoading}
error={error}

@ -20,17 +20,11 @@ export function GetAICourse(props: GetAICourseProps) {
const { data: aiCourse, error: queryError } = useQuery(
{
...getAiCourseOptions({ aiCourseSlug: courseSlug }),
enabled: !!courseSlug && !!isLoggedIn(),
enabled: !!courseSlug,
},
queryClient,
);
useEffect(() => {
if (!isLoggedIn()) {
window.location.href = '/ai';
}
}, [isLoggedIn]);
useEffect(() => {
if (!aiCourse) {
return;
@ -102,6 +96,7 @@ export function GetAICourse(props: GetAICourseProps) {
courseSlug={courseSlug}
error={error}
onRegenerateOutline={handleRegenerateCourse}
creatorId={aiCourse?.userId}
/>
);
}

@ -4,10 +4,17 @@ import { Modal } from '../Modal';
export type ModifyCoursePromptProps = {
onClose: () => void;
onSubmit: (prompt: string) => void;
title?: string;
description?: string;
};
export function ModifyCoursePrompt(props: ModifyCoursePromptProps) {
const { onClose, onSubmit } = props;
const {
onClose,
onSubmit,
title = 'Give AI more context',
description = 'Pass additional information to the AI to generate a course outline.',
} = props;
const [prompt, setPrompt] = useState('');
@ -25,12 +32,8 @@ export function ModifyCoursePrompt(props: ModifyCoursePromptProps) {
>
<div className="flex flex-col gap-4">
<div>
<h2 className="mb-2 text-left text-xl font-semibold">
Give AI more context
</h2>
<p className="text-sm text-gray-500">
Pass additional information to the AI to generate a course outline.
</p>
<h2 className="mb-2 text-left text-xl font-semibold">{title}</h2>
<p className="text-sm text-gray-500">{description}</p>
</div>
<form className="flex flex-col gap-2" onSubmit={handleSubmit}>
<textarea

@ -4,13 +4,17 @@ import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { ModifyCoursePrompt } from './ModifyCoursePrompt';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
type RegenerateLessonProps = {
onRegenerateLesson: (prompt?: string) => void;
isForkable: boolean;
onForkCourse: () => void;
};
export function RegenerateLesson(props: RegenerateLessonProps) {
const { onRegenerateLesson } = props;
const { onRegenerateLesson, isForkable, onForkCourse } = props;
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
@ -37,12 +41,22 @@ export function RegenerateLesson(props: RegenerateLessonProps) {
onClose={() => setShowPromptModal(false)}
onSubmit={(prompt) => {
setShowPromptModal(false);
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (isForkable) {
onForkCourse();
return;
}
onRegenerateLesson(prompt);
}}
/>
)}
<div className="relative mr-2 flex items-center" ref={ref}>
<div className="relative flex items-center lg:mr-1" ref={ref}>
<button
className={cn('rounded-full p-1 text-gray-400 hover:text-black', {
'text-black': isDropdownVisible,
@ -52,9 +66,20 @@ export function RegenerateLesson(props: RegenerateLessonProps) {
<PenSquare className="text-current" size={16} strokeWidth={2.5} />
</button>
{isDropdownVisible && (
<div className="absolute right-0 top-full min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white">
<div className="absolute top-full right-0 min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white">
<button
onClick={() => {
setIsDropdownVisible(false);
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (isForkable) {
onForkCourse();
return;
}
onRegenerateLesson();
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
@ -69,6 +94,16 @@ export function RegenerateLesson(props: RegenerateLessonProps) {
<button
onClick={() => {
setIsDropdownVisible(false);
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (isForkable) {
onForkCourse();
return;
}
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"

@ -7,10 +7,12 @@ import { ModifyCoursePrompt } from './ModifyCoursePrompt';
type RegenerateOutlineProps = {
onRegenerateOutline: (prompt?: string) => void;
isForkable: boolean;
onForkCourse: () => void;
};
export function RegenerateOutline(props: RegenerateOutlineProps) {
const { onRegenerateOutline } = props;
const { onRegenerateOutline, isForkable, onForkCourse } = props;
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
@ -35,27 +37,33 @@ export function RegenerateOutline(props: RegenerateOutlineProps) {
onClose={() => setShowPromptModal(false)}
onSubmit={(prompt) => {
setShowPromptModal(false);
if (isForkable) {
onForkCourse();
return;
}
onRegenerateOutline(prompt);
}}
/>
)}
<div ref={ref} className="flex relative items-stretch">
<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,
},
)}
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 right-0 top-full translate-y-1 min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
<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);
if (isForkable) {
onForkCourse();
return;
}
onRegenerateOutline();
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
@ -70,6 +78,10 @@ export function RegenerateOutline(props: RegenerateOutlineProps) {
<button
onClick={() => {
setIsDropdownVisible(false);
if (isForkable) {
onForkCourse();
return;
}
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"

@ -1,42 +1,32 @@
import { useQuery } from '@tanstack/react-query';
import { BookOpen } 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 {
getAiCourseLimitOptions,
listUserAiCoursesOptions,
type ListUserAiCoursesQuery,
} from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { AICourseCard } from './AICourseCard';
import { useEffect, useState } from 'react';
import { Gift, Loader2, User2 } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { cn } from '../../lib/classname';
import { useIsPaidUser } from '../../queries/billing';
import { AITutorHeader } from '../AITutor/AITutorHeader';
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { getUrlParams, setUrlParams, deleteUrlParam } from '../../lib/browser';
import { AICourseSearch } from './AICourseSearch';
import { Pagination } from '../Pagination/Pagination';
import { AICourseCard } from './AICourseCard';
import { AICourseSearch } from './AICourseSearch';
import { AILoadingState } from '../AITutor/AILoadingState';
type UserCoursesListProps = {};
export function UserCoursesList(props: UserCoursesListProps) {
export function UserCoursesList() {
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({
perPage: '10',
perPage: '21',
currPage: '1',
query: '',
});
const { data: limits, isLoading: isLimitsLoading } = useQuery(
getAiCourseLimitOptions(),
queryClient,
);
const { used, limit } = limits ?? { used: 0, limit: 0 };
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery(
listUserAiCoursesOptions(pageState),
queryClient,
@ -47,8 +37,6 @@ export function UserCoursesList(props: UserCoursesListProps) {
}, [userAiCourses]);
const courses = userAiCourses?.data ?? [];
const isAuthenticated = isLoggedIn();
const limitUsedPercentage = Math.round((used / limit) * 100);
useEffect(() => {
const queryParams = getUrlParams();
@ -72,101 +60,65 @@ export function UserCoursesList(props: UserCoursesListProps) {
}
}, [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();
}}
/>
);
}
return (
<>
{showUpgradePopup && (
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<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="text-lg font-semibold">
<span className="max-md:hidden">Your </span>Courses
</h2>
</div>
<div className="flex items-center gap-2">
{used > 0 && limit > 0 && !isPaidUserLoading && (
<div
className={cn(
'pointer-events-none flex items-center gap-2 opacity-0 transition-opacity',
{
'pointer-events-auto opacity-100': !isPaidUser,
},
)}
>
<p className="flex items-center text-sm text-yellow-600">
<span className="max-md:hidden">
{limitUsedPercentage}% of daily limit used{' '}
</span>
<span className="inline md:hidden">
{limitUsedPercentage}% used
</span>
<button
onClick={() => {
setShowUpgradePopup(true);
}}
className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pl-1.5 pr-2 text-xs text-white"
>
<Gift className="size-4" />
Upgrade
</button>
</p>
</div>
)}
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
setPageState({
...pageState,
query: value,
currPage: '1',
});
}}
/>
</div>
</div>
{!isInitialLoading && !isUserAiCoursesLoading && !isAuthenticated && (
<div className="flex min-h-[152px] flex-col items-center justify-center rounded-lg border border-gray-200 bg-white px-6 py-4">
<User2 className="mb-2 size-8 text-gray-300" />
<p className="max-w-sm text-balance text-center text-gray-500">
<button
onClick={() => {
showLoginPopup();
}}
className="font-medium text-black underline underline-offset-2 hover:opacity-80"
>
Sign up (free and takes 2s) or login
</button>{' '}
to generate and save courses.
</p>
</div>
)}
{!isUserAiCoursesLoading && !isInitialLoading && courses.length === 0 && isAuthenticated && (
<div className="flex min-h-[152px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4">
<p className="text-sm text-gray-600">
You haven't generated any courses yet.
</p>
</div>
)}
<AITutorHeader
title="Your Courses"
onUpgradeClick={() => setShowUpgradePopup(true)}
>
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
setPageState({
...pageState,
query: value,
currPage: '1',
});
}}
/>
</AITutorHeader>
{(isUserAiCoursesLoading || isInitialLoading) && (
<div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4">
<Loader2
className="size-4 animate-spin text-gray-400"
strokeWidth={2.5}
/>
<p className="text-sm font-medium text-gray-600">Loading...</p>
</div>
<AILoadingState
title="Loading your courses"
subtitle="This may take a moment..."
/>
)}
{!isUserAiCoursesLoading && courses && courses.length > 0 && (
{!isUserAiCoursesLoading && !isInitialLoading && courses.length > 0 && (
<div className="flex flex-col gap-2">
{courses.map((course) => (
<AICourseCard key={course._id} course={course} />
))}
<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>
<Pagination
totalCount={userAiCourses?.totalCount || 0}
@ -181,15 +133,17 @@ export function UserCoursesList(props: UserCoursesListProps) {
</div>
)}
{!isUserAiCoursesLoading &&
(userAiCourses?.data?.length || 0 > 0) &&
courses.length === 0 && (
<div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4">
<p className="text-sm text-gray-600">
No courses match your search.
</p>
</div>
)}
{!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';
}}
/>
)}
</>
);
}

@ -0,0 +1,36 @@
import type { SVGProps } from 'react';
type AITutorLogoProps = SVGProps<SVGSVGElement>;
export function AITutorLogo(props: AITutorLogoProps) {
return (
<svg
viewBox="0 0 310 248"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect width="310" height="247.211" fill="black" />
<path
d="M205.179 45.1641H263.851V201.278H205.179V45.1641Z"
fill="white"
/>
<path
d="M45.1641 45.1743H104.598L104.598 202.048H45.1641L45.1641 45.1743Z"
fill="white"
/>
<path
d="M160.984 45.1743V103.716L45.1641 103.716L45.1641 45.1743H160.984Z"
fill="white"
/>
<path
d="M125.171 45.1743H184.605V201.284H125.171V45.1743Z"
fill="white"
/>
<path
d="M159.841 131.85V173.88L63.8324 173.88L63.8324 131.85H159.841Z"
fill="white"
/>
</svg>
);
}

@ -0,0 +1,17 @@
---
import { AIExploreCourseListing } from '../../components/AITutor/AIExploreCourseListing';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { AITutorLayout } from '../../components/AITutor/AITutorLayout';
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.'
>
<AITutorLayout activeTab='community' client:load>
<AIExploreCourseListing client:load />
</AITutorLayout>
</SkeletonLayout>

@ -0,0 +1,17 @@
---
import { UserCoursesList } from '../../components/GenerateCourse/UserCoursesList';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { AITutorLayout } from '../../components/AITutor/AITutorLayout';
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.'
>
<AITutorLayout activeTab='courses' client:load>
<UserCoursesList client:load />
</AITutorLayout>
</SkeletonLayout>

@ -1,18 +1,20 @@
---
import { AICourse } from '../../components/GenerateCourse/AICourse';
import BaseLayout from '../../layouts/BaseLayout.astro';
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 { AITutorLayout } from '../../components/AITutor/AITutorLayout';
const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
---
<BaseLayout
<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.'
>
<div slot='course-announcement'></div>
<AICourse client:load />
<CheckSubscriptionVerification client:load />
</BaseLayout>
<AITutorLayout activeTab='new' client:load>
<AICourse client:load />
<CheckSubscriptionVerification client:load />
</AITutorLayout>
</SkeletonLayout>

@ -0,0 +1,17 @@
---
import { AIFeaturedCoursesListing } from '../../components/AITutor/AIFeaturedCoursesListing';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { AITutorLayout } from '../../components/AITutor/AITutorLayout';
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.'
>
<AITutorLayout activeTab='staff-picks' client:load>
<AIFeaturedCoursesListing client:load />
</AITutorLayout>
</SkeletonLayout>

@ -83,7 +83,7 @@ type ListUserAiCoursesResponse = {
export function listUserAiCoursesOptions(
params: ListUserAiCoursesQuery = {
perPage: '10',
perPage: '21',
currPage: '1',
query: '',
},
@ -99,3 +99,69 @@ export function listUserAiCoursesOptions(
enabled: !!isLoggedIn(),
};
}
type ListFeaturedAiCoursesParams = {};
type ListFeaturedAiCoursesQuery = {
perPage?: string;
currPage?: string;
};
type ListFeaturedAiCoursesResponse = {
data: AICourseWithLessonCount[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function listFeaturedAiCoursesOptions(
params: ListFeaturedAiCoursesQuery = {
perPage: '21',
currPage: '1',
},
) {
return {
queryKey: ['featured-ai-courses', params],
queryFn: () => {
return httpGet<ListFeaturedAiCoursesResponse>(
`/v1-list-featured-ai-courses`,
params,
);
},
};
}
type ListExploreAiCoursesParams = {};
export type ListExploreAiCoursesQuery = {
perPage?: string;
currPage?: string;
query?: string;
};
type ListExploreAiCoursesResponse = {
data: AICourseWithLessonCount[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function listExploreAiCoursesOptions(
params: ListExploreAiCoursesQuery = {
perPage: '21',
currPage: '1',
query: '',
},
) {
return {
queryKey: ['explore-ai-courses', params],
queryFn: () => {
return httpGet<ListExploreAiCoursesResponse>(
`/v1-list-explore-ai-courses`,
params,
);
},
};
}

Loading…
Cancel
Save