mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-12 17:51:53 +08:00
Compare commits
37 Commits
1fbc167494
...
feat/ai-tu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f22208de08 | ||
|
|
cb98829ce8 | ||
|
|
c28593142e | ||
|
|
74c20a66fc | ||
|
|
4df9eebb34 | ||
|
|
5a4cdc849f | ||
|
|
926a08ac6c | ||
|
|
a75f97360a | ||
|
|
fd9b388834 | ||
|
|
ec458f2fd2 | ||
|
|
618e4c1233 | ||
|
|
1970e0c92e | ||
|
|
b28eb5fecf | ||
|
|
2868fa3c27 | ||
|
|
ab981b8c88 | ||
|
|
3918112884 | ||
|
|
4fbea4680c | ||
|
|
61f5a81d20 | ||
|
|
b3ff46ea71 | ||
|
|
19e1dbc13a | ||
|
|
150d38af2b | ||
|
|
0f29273ff3 | ||
|
|
e660d9da15 | ||
|
|
a86d9b3ed0 | ||
|
|
204a421559 | ||
|
|
8a14d7011c | ||
|
|
0c6c6e0246 | ||
|
|
9156b4b700 | ||
|
|
f95ef58a93 | ||
|
|
653778b13d | ||
|
|
4cf3f052f7 | ||
|
|
bbe716cecf | ||
|
|
d0f8fc4e6a | ||
|
|
d1208047a5 | ||
|
|
69ed5d79de | ||
|
|
da14f05079 | ||
|
|
3a20912f0f |
128
src/components/AITutor/AIExploreCourseListing.tsx
Normal file
128
src/components/AITutor/AIExploreCourseListing.tsx
Normal file
@@ -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';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
131
src/components/AITutor/AIFeaturedCoursesListing.tsx
Normal file
131
src/components/AITutor/AIFeaturedCoursesListing.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
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';
|
||||
import { AICourseSearch } from '../GenerateCourse/AICourseSearch';
|
||||
|
||||
export function AIFeaturedCoursesListing() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||
|
||||
const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({
|
||||
perPage: '21',
|
||||
currPage: '1',
|
||||
query: '',
|
||||
});
|
||||
|
||||
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',
|
||||
query: queryParams?.q || '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageState?.currPage !== '1' || pageState?.query !== '') {
|
||||
setUrlParams({
|
||||
p: pageState?.currPage || '1',
|
||||
q: pageState?.query || '',
|
||||
});
|
||||
} else {
|
||||
deleteUrlParam('p');
|
||||
deleteUrlParam('q');
|
||||
}
|
||||
}, [pageState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradePopup && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
|
||||
)}
|
||||
|
||||
<AITutorHeader
|
||||
title="Featured Courses"
|
||||
onUpgradeClick={() => setShowUpgradePopup(true)}
|
||||
>
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AITutorHeader>
|
||||
|
||||
{(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';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/components/AITutor/AILoadingState.tsx
Normal file
27
src/components/AITutor/AILoadingState.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
40
src/components/AITutor/AITutorHeader.tsx
Normal file
40
src/components/AITutor/AITutorHeader.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
42
src/components/AITutor/AITutorLayout.tsx
Normal file
42
src/components/AITutor/AITutorLayout.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
src/components/AITutor/AITutorLimits.tsx
Normal file
45
src/components/AITutor/AITutorLimits.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
143
src/components/AITutor/AITutorSidebar.tsx
Normal file
143
src/components/AITutor/AITutorSidebar.tsx
Normal file
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
src/components/AITutor/AITutorSidebarProps.tsx
Normal file
13
src/components/AITutor/AITutorSidebarProps.tsx
Normal file
@@ -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>
|
||||
31
src/components/AITutor/AITutorTallMessage.tsx
Normal file
31
src/components/AITutor/AITutorTallMessage.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
69
src/components/AITutor/DifficultyDropdown.tsx
Normal file
69
src/components/AITutor/DifficultyDropdown.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
38
src/components/AITutor/LoginToView.tsx
Normal file
38
src/components/AITutor/LoginToView.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { LockIcon } from 'lucide-react';
|
||||
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type LoginToViewProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LoginToView(props: LoginToViewProps) {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-8 min-h-[402px] rounded-xl border border-gray-200/50 bg-gradient-to-br from-gray-50 to-gray-100/50 p-12 backdrop-blur-sm',
|
||||
'flex flex-col items-center justify-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LockIcon className="size-8 stroke-[1.5] text-gray-600" />
|
||||
|
||||
<div className="mt-5 mb-4 flex flex-col items-center gap-0.5 text-center">
|
||||
<h3 className="text-xl font-semibold text-gray-700">Login Required</h3>
|
||||
<p className="text-sm text-balance leading-relaxed text-gray-500">
|
||||
Please login to access the content and all the features of the AI Tutor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => showLoginPopup()}
|
||||
className="rounded-full bg-black px-6 py-2 text-sm font-medium text-white transition-all duration-300 hover:opacity-80 hover:shadow-md active:scale-[0.98] active:transform"
|
||||
>
|
||||
Login to Continue
|
||||
</button>
|
||||
</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="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-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">
|
||||
<label
|
||||
htmlFor="keyword"
|
||||
className="mb-2.5 text-sm font-medium 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}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-2.5 text-sm font-medium text-gray-700">
|
||||
Difficulty Level
|
||||
<label
|
||||
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"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasFineTuneData}
|
||||
onChange={() => setHasFineTuneData(!hasFineTuneData)}
|
||||
className="mr-1"
|
||||
id="fine-tune-checkbox"
|
||||
/>
|
||||
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>
|
||||
|
||||
<div className="mt-8 min-h-[200px]">
|
||||
<UserCoursesList />
|
||||
</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,8 @@ import {
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from './Resizeable';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { LoginToView } from '../AITutor/LoginToView';
|
||||
|
||||
function getQuestionsFromResult(result: string) {
|
||||
const matchedQuestions = result.match(
|
||||
@@ -55,6 +58,7 @@ function getQuestionsFromResult(result: string) {
|
||||
type AICourseLessonProps = {
|
||||
courseSlug: string;
|
||||
progress: string[];
|
||||
creatorId?: string;
|
||||
|
||||
activeModuleIndex: number;
|
||||
totalModules: number;
|
||||
@@ -70,12 +74,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 +99,9 @@ export function AICourseLesson(props: AICourseLessonProps) {
|
||||
|
||||
isAIChatsOpen,
|
||||
setIsAIChatsOpen,
|
||||
|
||||
isForkable,
|
||||
onForkCourse,
|
||||
} = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -108,8 +119,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 +215,7 @@ export function AICourseLesson(props: AICourseLessonProps) {
|
||||
|
||||
const questions = getQuestionsFromResult(result);
|
||||
setDefaultQuestions(questions);
|
||||
|
||||
|
||||
const newResult = result.replace(
|
||||
/=START_QUESTIONS=.*?=END_QUESTIONS=/,
|
||||
'',
|
||||
@@ -284,7 +294,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 +303,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 +325,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 +389,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 }}
|
||||
/>
|
||||
)}
|
||||
@@ -400,14 +434,7 @@ export function AICourseLesson(props: AICourseLessonProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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" />
|
||||
<p className="text-sm text-gray-500">
|
||||
Please login to generate course content
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoggedIn() && <LoginToView />}
|
||||
|
||||
{!isLoading && !isGenerating && !error && (
|
||||
<TestMyKnowledgeAction
|
||||
@@ -436,6 +463,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 / Register
|
||||
</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">
|
||||
|
||||
@@ -24,6 +24,8 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { AICourseOutlineHeader } from './AICourseOutlineHeader';
|
||||
import type { AiCourse } from '../../lib/ai';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { LoginToView } from '../AITutor/LoginToView';
|
||||
|
||||
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}`,
|
||||
@@ -206,7 +217,7 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto min-h-[500px] rounded-xl border border-gray-200 bg-white shadow-xs lg:max-w-5xl">
|
||||
<div className="relative overflow-hidden mx-auto min-h-[500px] rounded-xl border border-gray-200 bg-white shadow-xs lg:max-w-5xl">
|
||||
<AICourseOutlineHeader
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
@@ -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,12 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isGenerating && (
|
||||
{!isLoggedIn() && <LoginToView className="-mt-1 -mb-2 rounded-none border-none" />}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
40
src/components/GenerateCourse/ForkCourseAlert.tsx
Normal file
40
src/components/GenerateCourse/ForkCourseAlert.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
99
src/components/GenerateCourse/ForkCourseConfirmation.tsx
Normal file
99
src/components/GenerateCourse/ForkCourseConfirmation.tsx
Normal file
@@ -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';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
36
src/components/ReactIcons/AITutorLogo.tsx
Normal file
36
src/components/ReactIcons/AITutorLogo.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
17
src/pages/ai/community.astro
Normal file
17
src/pages/ai/community.astro
Normal file
@@ -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>
|
||||
17
src/pages/ai/courses.astro
Normal file
17
src/pages/ai/courses.astro
Normal file
@@ -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>
|
||||
|
||||
17
src/pages/ai/staff-picks.astro
Normal file
17
src/pages/ai/staff-picks.astro
Normal file
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user