diff --git a/src/components/AITutor/AIExploreCourseListing.tsx b/src/components/AITutor/AIExploreCourseListing.tsx new file mode 100644 index 000000000..15ac7c9ef --- /dev/null +++ b/src/components/AITutor/AIExploreCourseListing.tsx @@ -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({ + 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 && ( + setShowUpgradePopup(false)} /> + )} + + setShowUpgradePopup(true)} + > + { + setPageState({ + ...pageState, + query: value, + currPage: '1', + }); + }} + /> + + + {(isInitialLoading || isExploreAiCoursesLoading) && ( + + )} + + {!isExploreAiCoursesLoading && courses && courses.length > 0 && ( +
+
+ {courses.map((course) => ( + + ))} +
+ + { + setPageState({ ...pageState, currPage: String(page) }); + }} + className="rounded-lg border border-gray-200 bg-white p-4" + /> +
+ )} + + {!isInitialLoading && + !isExploreAiCoursesLoading && + courses.length === 0 && ( + { + window.location.href = '/ai'; + }} + /> + )} + + ); +} diff --git a/src/components/AITutor/AIFeaturedCoursesListing.tsx b/src/components/AITutor/AIFeaturedCoursesListing.tsx new file mode 100644 index 000000000..0023cda29 --- /dev/null +++ b/src/components/AITutor/AIFeaturedCoursesListing.tsx @@ -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({ + 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 && ( + setShowUpgradePopup(false)} /> + )} + + setShowUpgradePopup(true)} + /> + + {(isFeaturedAiCoursesLoading || isInitialLoading) && ( + + )} + + {!isFeaturedAiCoursesLoading && + !isInitialLoading && + courses.length > 0 && ( +
+
+ {courses.map((course) => ( + + ))} +
+ + { + setPageState({ ...pageState, currPage: String(page) }); + }} + className="rounded-lg border border-gray-200 bg-white p-4" + /> +
+ )} + + {!isFeaturedAiCoursesLoading && + !isInitialLoading && + courses.length === 0 && ( + { + window.location.href = '/ai'; + }} + /> + )} + + ); +} diff --git a/src/components/AITutor/AILoadingState.tsx b/src/components/AITutor/AILoadingState.tsx new file mode 100644 index 000000000..cdaad1020 --- /dev/null +++ b/src/components/AITutor/AILoadingState.tsx @@ -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 ( +
+
+ +
+
+
+
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/AITutor/AITutorHeader.tsx b/src/components/AITutor/AITutorHeader.tsx new file mode 100644 index 000000000..154a87865 --- /dev/null +++ b/src/components/AITutor/AITutorHeader.tsx @@ -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 ( +
+
+

{title}

+
+ +
+ + + {children} +
+
+ ); +} diff --git a/src/components/AITutor/AITutorLayout.tsx b/src/components/AITutor/AITutorLayout.tsx new file mode 100644 index 000000000..b50849495 --- /dev/null +++ b/src/components/AITutor/AITutorLayout.tsx @@ -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 ( + <> +
+ + + + +
+ +
+ setIsSidebarFloating(false)} + isFloating={isSidebarFloating} + activeTab={activeTab} + /> +
+ {children} +
+
+ + ); +} diff --git a/src/components/AITutor/AITutorLimits.tsx b/src/components/AITutor/AITutorLimits.tsx new file mode 100644 index 000000000..3cc93730e --- /dev/null +++ b/src/components/AITutor/AITutorLimits.tsx @@ -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 ( +
+

+ + {limitUsedPercentage}% of daily limit used{' '} + + + {limitUsedPercentage}% used + + +

+
+ ); +} \ No newline at end of file diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx new file mode 100644 index 000000000..471d75849 --- /dev/null +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -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 && ( + setIsUpgradeModalOpen(false)} /> + )} + + + {isFloating && ( +
+ )} + + ); +} diff --git a/src/components/AITutor/AITutorSidebarProps.tsx b/src/components/AITutor/AITutorSidebarProps.tsx new file mode 100644 index 000000000..8ede3e1a4 --- /dev/null +++ b/src/components/AITutor/AITutorSidebarProps.tsx @@ -0,0 +1,13 @@ +import { Zap } from 'lucide-react'; + +
  • +
    +
    + + Free Tier +
    +

    + Upgrade to Pro to unlock unlimited AI tutoring sessions +

    +
    +
  • \ No newline at end of file diff --git a/src/components/AITutor/AITutorTallMessage.tsx b/src/components/AITutor/AITutorTallMessage.tsx new file mode 100644 index 000000000..a990fe885 --- /dev/null +++ b/src/components/AITutor/AITutorTallMessage.tsx @@ -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 ( +
    + +
    +

    {title}

    + {subtitle &&

    {subtitle}

    } +
    + {buttonText && onButtonClick && ( + + )} +
    + ); +} diff --git a/src/components/AITutor/DifficultyDropdown.tsx b/src/components/AITutor/DifficultyDropdown.tsx new file mode 100644 index 000000000..0f5320090 --- /dev/null +++ b/src/components/AITutor/DifficultyDropdown.tsx @@ -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(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 ( +
    + + + {isOpen && ( +
    + {difficultyLevels.map((level) => ( + + ))} +
    + )} +
    + ); +} diff --git a/src/components/Billing/UpgradeAccountModal.tsx b/src/components/Billing/UpgradeAccountModal.tsx index 452fd2665..359c4acab 100644 --- a/src/components/Billing/UpgradeAccountModal.tsx +++ b/src/components/Billing/UpgradeAccountModal.tsx @@ -234,7 +234,14 @@ export function UpgradeAccountModal(props: UpgradeAccountModalProps) { )}

    )} -

    +

    ${plan.amount}{' '} / {isYearly ? 'year' : 'month'} diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index afbb3fcbf..2e6fffb70 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -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 ( -

    -
    -

    - Learn anything with AI -

    -

    - Enter a topic below to generate a personalized course for it -

    - -
    -
    { - e.preventDefault(); - onSubmit(); - }} - > -
    +
    +

    + What can I help you learn? +

    +

    + Enter a topic below to generate a personalized course for it +

    + +
    + { + e.preventDefault(); + onSubmit(); + }} + > + 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} + /> + +
    +
    +
    + +
    -
    -
    - -
    setKeyword(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="e.g., Algebra, JavaScript, Photography" - className="w-full rounded-md border border-gray-300 bg-white p-3 pl-10 text-gray-900 focus:outline-hidden focus:ring-1 focus:ring-gray-500 max-sm:placeholder:text-base" - maxLength={50} + type="checkbox" + checked={hasFineTuneData} + onChange={() => setHasFineTuneData(!hasFineTuneData)} + className="mr-1" + id="fine-tune-checkbox" /> -
    -
    - -
    - -
    - {difficultyLevels.map((level) => ( - - ))} -
    - - - -
    - -
    - -
    +
    + + + + +
    -
    +
    ); } diff --git a/src/components/GenerateCourse/AICourseCard.tsx b/src/components/GenerateCourse/AICourseCard.tsx index 5c524885a..9db197881 100644 --- a/src/components/GenerateCourse/AICourseCard.tsx +++ b/src/components/GenerateCourse/AICourseCard.tsx @@ -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 ( -
    +
    {totalTopics} lessons
    - {totalTopics > 0 && ( + {showProgress && totalTopics > 0 && (
    - {course.slug && ( -
    + {showActions && course.slug && ( +
    )} diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index e0364fe33..ba484cd76 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -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('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) {
    Create a course with AI @@ -214,6 +228,7 @@ export function AICourseContent(props: AICourseContentProps) { } const isViewingLesson = viewMode === 'module'; + const isForkable = !!currentUser?.id && currentUser.id !== creatorId; return (
    @@ -233,7 +248,10 @@ export function AICourseContent(props: AICourseContentProps) { aria-label="Back to generator" > - Back {isViewingLesson ? 'to Outline' : 'to AI Tutor'} + Back{' '} + + {isViewingLesson ? 'to Outline' : 'to AI Tutor'} +
    @@ -272,7 +290,7 @@ export function AICourseContent(props: AICourseContentProps) {
    -

    +

    {course.title || 'Loading Course...'}

    @@ -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', )} > @@ -420,9 +438,30 @@ export function AICourseContent(props: AICourseContentProps) { )} key={`${courseSlug}-${viewMode}`} > + {isForkable && + courseSlug && + (viewMode === 'outline' || viewMode === 'roadmap') && ( + { + setIsForkingCourse(true); + }} + /> + )} + + {isForkingCourse && ( + { + setIsForkingCourse(false); + }} + courseSlug={courseSlug!} + /> + )} + {viewMode === 'module' && ( 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); + }} /> )} diff --git a/src/components/GenerateCourse/AICourseLesson.tsx b/src/components/GenerateCourse/AICourseLesson.tsx index 3264c729d..28c053521 100644 --- a/src/components/GenerateCourse/AICourseLesson.tsx +++ b/src/components/GenerateCourse/AICourseLesson.tsx @@ -3,6 +3,7 @@ import { CheckIcon, ChevronLeft, ChevronRight, + GitForkIcon, Loader2Icon, LockIcon, MessageCircleIcon, @@ -39,6 +40,7 @@ import { ResizablePanel, ResizablePanelGroup, } from './Resizeable'; +import { showLoginPopup } from '../../lib/popup'; function getQuestionsFromResult(result: string) { const matchedQuestions = result.match( @@ -55,6 +57,7 @@ function getQuestionsFromResult(result: string) { type AICourseLessonProps = { courseSlug: string; progress: string[]; + creatorId?: string; activeModuleIndex: number; totalModules: number; @@ -70,12 +73,16 @@ type AICourseLessonProps = { isAIChatsOpen: boolean; setIsAIChatsOpen: (isOpen: boolean) => void; + + isForkable: boolean; + onForkCourse: () => void; }; export function AICourseLesson(props: AICourseLessonProps) { const { courseSlug, progress = [], + creatorId, activeModuleIndex, totalModules, @@ -91,6 +98,9 @@ export function AICourseLesson(props: AICourseLessonProps) { isAIChatsOpen, setIsAIChatsOpen, + + isForkable, + onForkCourse, } = props; const [isLoading, setIsLoading] = useState(true); @@ -108,8 +118,7 @@ export function AICourseLesson(props: AICourseLessonProps) { >([ { role: 'assistant', - content: - 'Hey, I am your AI instructor. How can I help you today? 🤖', + content: 'Hey, I am your AI instructor. How can I help you today? 🤖', isDefault: true, }, ]); @@ -205,7 +214,7 @@ export function AICourseLesson(props: AICourseLessonProps) { const questions = getQuestionsFromResult(result); setDefaultQuestions(questions); - + const newResult = result.replace( /=START_QUESTIONS=.*?=END_QUESTIONS=/, '', @@ -284,7 +293,7 @@ export function AICourseLesson(props: AICourseLessonProps) {
    {(isGenerating || isLoading) && ( -
    +
    )} -
    +
    Lesson {activeLessonIndex + 1} of {totalLessons}
    {!isGenerating && !isLoading && ( -
    +
    + )}
    -

    +

    {currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}

    {!error && isLoggedIn() && (
    )} @@ -402,10 +435,18 @@ export function AICourseLesson(props: AICourseLessonProps) { {!isLoggedIn() && (
    - +

    Please login to generate course content

    +
    )} @@ -436,6 +477,11 @@ export function AICourseLesson(props: AICourseLessonProps) {

    - + AI Instructor

    +
    + )}
    {icon} - + {title}
    diff --git a/src/components/GenerateCourse/AICourseLimit.tsx b/src/components/GenerateCourse/AICourseLimit.tsx index f2c80a80b..d41a4d699 100644 --- a/src/components/GenerateCourse/AICourseLimit.tsx +++ b/src/components/GenerateCourse/AICourseLimit.tsx @@ -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 (
    diff --git a/src/components/GenerateCourse/AICourseOutlineHeader.tsx b/src/components/GenerateCourse/AICourseOutlineHeader.tsx index a841c99bb..d099bfa83 100644 --- a/src/components/GenerateCourse/AICourseOutlineHeader.tsx +++ b/src/components/GenerateCourse/AICourseOutlineHeader.tsx @@ -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 (
    -

    +

    {course.title || 'Loading course ..'}

    -

    +

    {course.title ? course.difficulty : 'Please wait ..'}

    -
    +
    {!isLoading && ( <> - +
    +
    + )} + + {error && !isGenerating && !isLoggedIn() && (
    -

    +

    {error || 'Something went wrong'}

    diff --git a/src/components/GenerateCourse/FineTuneCourse.tsx b/src/components/GenerateCourse/FineTuneCourse.tsx index 44d967b0f..e727c8dfb 100644 --- a/src/components/GenerateCourse/FineTuneCourse.tsx +++ b/src/components/GenerateCourse/FineTuneCourse.tsx @@ -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 ( -
    - + if (!hasFineTuneData) { + return null; + } - {hasFineTuneData && ( -
    - - - -
    - )} + return ( +
    + + +
    ); } diff --git a/src/components/GenerateCourse/ForkCourseAlert.tsx b/src/components/GenerateCourse/ForkCourseAlert.tsx new file mode 100644 index 000000000..b00658246 --- /dev/null +++ b/src/components/GenerateCourse/ForkCourseAlert.tsx @@ -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 ( +
    +

    + Fork the course to track progress and make changes to the course. +

    + + +
    + ); +} diff --git a/src/components/GenerateCourse/ForkCourseConfirmation.tsx b/src/components/GenerateCourse/ForkCourseConfirmation.tsx new file mode 100644 index 000000000..7073a2a8a --- /dev/null +++ b/src/components/GenerateCourse/ForkCourseConfirmation.tsx @@ -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 ( + {} : onClose} + wrapperClassName="h-auto items-start max-w-md w-full" + overlayClassName="items-start md:items-center" + > +
    +
    + +
    + +
    +

    Fork Course

    +

    + Create a copy of this course to track your progress and make changes + to suit your learning style. +

    +
    + +
    + + + +
    +
    +
    + ); +} diff --git a/src/components/GenerateCourse/GenerateAICourse.tsx b/src/components/GenerateCourse/GenerateAICourse.tsx index 8b901ad94..092cc0964 100644 --- a/src/components/GenerateCourse/GenerateAICourse.tsx +++ b/src/components/GenerateCourse/GenerateAICourse.tsx @@ -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 ( { - 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} /> ); } diff --git a/src/components/GenerateCourse/ModifyCoursePrompt.tsx b/src/components/GenerateCourse/ModifyCoursePrompt.tsx index 35583635d..94824f822 100644 --- a/src/components/GenerateCourse/ModifyCoursePrompt.tsx +++ b/src/components/GenerateCourse/ModifyCoursePrompt.tsx @@ -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) { >
    -

    - Give AI more context -

    -

    - Pass additional information to the AI to generate a course outline. -

    +

    {title}

    +

    {description}