Merge c28593142e
into d4a1180c4d
commit
9f384f0f43
35 changed files with 1428 additions and 330 deletions
@ -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> |
||||
); |
||||
} |
@ -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> |
||||
); |
||||
} |
@ -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> |
Loading…
Reference in new issue