diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index e0364fe33..6dc8aeec6 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 (
@@ -272,7 +287,7 @@ export function AICourseContent(props: AICourseContentProps) {
-

+

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

@@ -342,7 +357,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,6 +435,27 @@ export function AICourseContent(props: AICourseContentProps) { )} key={`${courseSlug}-${viewMode}`} > + {isForkable && + courseSlug && + (viewMode === 'outline' || viewMode === 'roadmap') && ( + { + setIsForkingCourse(true); + }} + /> + )} + + {isForkingCourse && ( + { + setIsForkingCourse(false); + }} + courseSlug={courseSlug!} + /> + )} + {viewMode === 'module' && ( { + setIsForkingCourse(true); + }} /> )} diff --git a/src/components/GenerateCourse/AICourseOutlineHeader.tsx b/src/components/GenerateCourse/AICourseOutlineHeader.tsx index a841c99bb..386efc7cb 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 && ( <> - +
+
+ ); +} diff --git a/src/components/GenerateCourse/ForkCourseConfirmation.tsx b/src/components/GenerateCourse/ForkCourseConfirmation.tsx new file mode 100644 index 000000000..b0c19f67b --- /dev/null +++ b/src/components/GenerateCourse/ForkCourseConfirmation.tsx @@ -0,0 +1,84 @@ +import { 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}> +
+ +

Fork Course

+

+ Forking this course will create a new course with the same content. +

+ +
+ + + +
+
+
+ ); +} diff --git a/src/components/GenerateCourse/GetAICourse.tsx b/src/components/GenerateCourse/GetAICourse.tsx index e9b1c98a3..dfe45de9f 100644 --- a/src/components/GenerateCourse/GetAICourse.tsx +++ b/src/components/GenerateCourse/GetAICourse.tsx @@ -102,6 +102,7 @@ export function GetAICourse(props: GetAICourseProps) { courseSlug={courseSlug} error={error} onRegenerateOutline={handleRegenerateCourse} + creatorId={aiCourse?.userId} /> ); } diff --git a/src/components/GenerateCourse/RegenerateOutline.tsx b/src/components/GenerateCourse/RegenerateOutline.tsx index 57634af2f..45005c4ef 100644 --- a/src/components/GenerateCourse/RegenerateOutline.tsx +++ b/src/components/GenerateCourse/RegenerateOutline.tsx @@ -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); }} /> )} -
+
{isDropdownVisible && ( -
+