feat/ai-tutor-redesign
Arik Chakma 4 days ago
parent bbe716cecf
commit 4cf3f052f7
  1. 52
      src/components/GenerateCourse/AICourseContent.tsx
  2. 34
      src/components/GenerateCourse/AICourseOutlineHeader.tsx
  3. 6
      src/components/GenerateCourse/AICourseOutlineView.tsx
  4. 34
      src/components/GenerateCourse/ForkCourseAlert.tsx
  5. 84
      src/components/GenerateCourse/ForkCourseConfirmation.tsx
  6. 1
      src/components/GenerateCourse/GetAICourse.tsx
  7. 30
      src/components/GenerateCourse/RegenerateOutline.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<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">
@ -272,7 +287,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 +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',
)}
></span>
@ -420,6 +435,27 @@ export function AICourseContent(props: AICourseContentProps) {
)}
key={`${courseSlug}-${viewMode}`}
>
{isForkable &&
courseSlug &&
(viewMode === 'outline' || viewMode === 'roadmap') && (
<ForkCourseAlert
courseSlug={courseSlug}
creatorId={creatorId}
onForkCourse={() => {
setIsForkingCourse(true);
}}
/>
)}
{isForkingCourse && (
<ForkCourseConfirmation
onClose={() => {
setIsForkingCourse(false);
}}
courseSlug={courseSlug!}
/>
)}
{viewMode === 'module' && (
<AICourseLesson
courseSlug={courseSlug!}
@ -450,6 +486,10 @@ export function AICourseContent(props: AICourseContentProps) {
setViewMode={setViewMode}
setExpandedModules={setExpandedModules}
viewMode={viewMode}
isForkable={isForkable}
onForkCourse={() => {
setIsForkingCourse(true);
}}
/>
)}

@ -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,14 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
<span>Outline</span>
</button>
<button
onClick={() => setViewMode('roadmap')}
onClick={() => {
if (isForkable) {
onForkCourse();
return;
}
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">

@ -0,0 +1,34 @@
import { GitForkIcon } from 'lucide-react';
import { getUser } from '../../lib/jwt';
type ForkCourseAlertProps = {
courseSlug: string;
creatorId?: string;
onForkCourse: () => void;
};
export function ForkCourseAlert(props: ForkCourseAlertProps) {
const { courseSlug, creatorId, onForkCourse } = props;
const currentUser = getUser();
if (!currentUser || !creatorId || currentUser?.id === creatorId) {
return null;
}
return (
<div className="mb-4 flex items-center justify-between gap-2 rounded-lg bg-yellow-200 p-3 text-black">
<p className="text-sm text-balance">
To start tracking your progress, you can fork the course.
</p>
<button
className="flex shrink-0 items-center gap-2 rounded-md bg-yellow-400 p-1 px-2 text-sm text-black"
onClick={onForkCourse}
>
<GitForkIcon className="size-3.5" />
Fork Course
</button>
</div>
);
}

@ -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 (
<Modal onClose={isPending ? () => {} : onClose}>
<div className="flex flex-col items-center p-4 pt-8">
<GitForkIcon className="size-14 text-gray-500" />
<p className="mt-2 text-xl font-medium">Fork Course</p>
<p className="mt-1 text-center text-balance text-gray-500">
Forking this course will create a new course with the same content.
</p>
<div className="mt-4 grid w-full grid-cols-2 gap-2">
<button
disabled={isPending}
className="flex items-center justify-center gap-2 rounded-md bg-gray-100 p-2 hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
disabled={isPending}
className="flex h-10 items-center justify-center gap-2 rounded-md bg-black p-2 text-white hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => {
forkCourse();
}}
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
'Fork Course'
)}
</button>
</div>
</div>
</Modal>
);
}

@ -102,6 +102,7 @@ export function GetAICourse(props: GetAICourseProps) {
courseSlug={courseSlug}
error={error}
onRegenerateOutline={handleRegenerateCourse}
creatorId={aiCourse?.userId}
/>
);
}

@ -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"

Loading…
Cancel
Save