feat: ai document (#8793)
* Refactor AI course * Add AI course generation functionality * Add basic error handling * AI Document content * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * feat: regenerate guide * wip * wip * wip * wip * wip * fix: form ui * feat: update guide ui * refactor: update the course card * fix: term and redirects * Guide page UI improvements * Loading chip refactor * AI guide changes * Improve UI for ai guide content * Add AI guide * AI Guide chat * fix: stop streaming * fix: chat responsiveness * UI improvements for ai library * Guide listing UI update * User guides listing * Library guides listing UI * Library guides listing UI * Featured courses listing UI update * Staff picks UI changes * Community page UI design * Explore courses listing functionality * Improve UI for explore page * Implement guides functionality --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
1 parent
469f4ca530
commit
89932bc18d
56 files changed
+3959
-280
No files matched your search
@@ -4,6 +4,7 @@ import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { FineTuneCourse } from './FineTuneCourse';
|
||||
import { DifficultyDropdown } from '../AITutor/DifficultyDropdown';
|
||||
import { NatureDropdown, type NatureType } from '../AITutor/NatureDropdown';
|
||||
import {
|
||||
clearFineTuneData,
|
||||
getCourseFineTuneData,
|
||||
@@ -26,6 +27,7 @@ type AICourseProps = {};
|
||||
export function AICourse(props: AICourseProps) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [difficulty, setDifficulty] = useState<DifficultyLevel>('beginner');
|
||||
const [nature, setNature] = useState<NatureType>('course');
|
||||
|
||||
const [hasFineTuneData, setHasFineTuneData] = useState(false);
|
||||
const [about, setAbout] = useState('');
|
||||
@@ -81,7 +83,11 @@ export function AICourse(props: AICourseProps) {
|
||||
});
|
||||
}
|
||||
|
||||
window.location.href = `/ai/search?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}`;
|
||||
if (nature === 'course') {
|
||||
window.location.href = `/ai/course?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}&nature=${nature}`;
|
||||
} else {
|
||||
window.location.href = `/ai/document?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}&nature=${nature}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -131,6 +137,7 @@ export function AICourse(props: AICourseProps) {
|
||||
<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">
|
||||
<NatureDropdown value={nature} onChange={setNature} />
|
||||
<DifficultyDropdown
|
||||
value={difficulty}
|
||||
onChange={setDifficulty}
|
||||
@@ -148,7 +155,6 @@ export function AICourse(props: AICourseProps) {
|
||||
id="fine-tune-checkbox"
|
||||
/>
|
||||
Explain more
|
||||
<span className="hidden md:inline"> for a better course</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +175,6 @@ export function AICourse(props: AICourseProps) {
|
||||
|
||||
<FineTuneCourse
|
||||
hasFineTuneData={hasFineTuneData}
|
||||
setHasFineTuneData={setHasFineTuneData}
|
||||
about={about}
|
||||
goal={goal}
|
||||
customInstructions={customInstructions}
|
||||
|
||||
@@ -2,17 +2,24 @@ import type { AICourseWithLessonCount } from '../../queries/ai-course';
|
||||
import type { DifficultyLevel } from './AICourse';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { AICourseActions } from './AICourseActions';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type AICourseCardProps = {
|
||||
course: AICourseWithLessonCount;
|
||||
showActions?: boolean;
|
||||
showProgress?: boolean;
|
||||
variant?: 'row' | 'column';
|
||||
};
|
||||
|
||||
export function AICourseCard(props: AICourseCardProps) {
|
||||
const { course, showActions = true, showProgress = true } = props;
|
||||
const {
|
||||
course,
|
||||
showActions = true,
|
||||
showProgress = true,
|
||||
variant = 'row',
|
||||
} = props;
|
||||
|
||||
// Map difficulty to color
|
||||
const difficultyColor =
|
||||
{
|
||||
beginner: 'text-green-700',
|
||||
@@ -20,49 +27,65 @@ export function AICourseCard(props: AICourseCardProps) {
|
||||
advanced: 'text-purple-700',
|
||||
}[course.difficulty as DifficultyLevel] || 'text-gray-700';
|
||||
|
||||
// Calculate progress percentage
|
||||
const modulesCount = course.modules?.length || 0;
|
||||
const totalTopics = course.lessonCount || 0;
|
||||
const completedTopics = course.done?.length || 0;
|
||||
const progressPercentage =
|
||||
totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0;
|
||||
const updatedAgo = getRelativeTimeString(course?.updatedAt);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-grow flex-col">
|
||||
<div className="relative flex flex-grow">
|
||||
<a
|
||||
href={`/ai/${course.slug}`}
|
||||
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"
|
||||
className={cn(
|
||||
'group relative flex h-full w-full gap-3 overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:border-gray-300 hover:bg-gray-50 sm:gap-4',
|
||||
variant === 'column' && 'flex-col',
|
||||
variant === 'row' && 'flex-row sm:flex-row sm:items-center',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
|
||||
>
|
||||
{course.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="my-2 text-base font-semibold text-gray-900">
|
||||
{course.title}
|
||||
</h3>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-2">
|
||||
<div className="flex items-center text-xs text-gray-600">
|
||||
<BookOpen className="mr-1 h-3.5 w-3.5" />
|
||||
<span>{totalTopics} lessons</span>
|
||||
{/* Title and difficulty section */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
|
||||
>
|
||||
{course.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showProgress && totalTopics > 0 && (
|
||||
<h3 className="line-clamp-2 text-base font-semibold text-balance text-gray-900">
|
||||
{course.title
|
||||
?.replace(": A Beginner's Guide", '')
|
||||
?.replace(' for beginners', '')
|
||||
?.replace(': A Comprehensive Guide', '')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Course stats section */}
|
||||
<div className="mt-7 flex items-center gap-4 sm:gap-4">
|
||||
<div className="hidden items-center text-xs text-gray-600 sm:flex">
|
||||
<BookOpen className="mr-1 h-3.5 w-3.5" />
|
||||
<span>{modulesCount} modules</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-2 h-1.5 w-16 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-600"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
{progressPercentage}%
|
||||
</span>
|
||||
<BookOpen className="mr-1 h-3.5 w-3.5" />
|
||||
<span>{totalTopics} lessons</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProgress && totalTopics > 0 && (
|
||||
<>
|
||||
<span className="hidden text-gray-400 sm:inline">•</span>
|
||||
<div className="flex items-center">
|
||||
<span className="flex items-center text-xs font-medium text-gray-700">
|
||||
{progressPercentage}% complete
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ import { useDebounceValue } from '../../hooks/use-debounce';
|
||||
type AICourseSearchProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function AICourseSearch(props: AICourseSearchProps) {
|
||||
const { value: defaultValue, onChange } = props;
|
||||
const { value: defaultValue, onChange, placeholder, disabled } = props;
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(defaultValue);
|
||||
const debouncedSearchTerm = useDebounceValue(searchTerm, 500);
|
||||
@@ -30,16 +32,17 @@ export function AICourseSearch(props: AICourseSearchProps) {
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
return (
|
||||
<div className="relative w-64 max-sm:hidden">
|
||||
<div className="relative mb-4">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<SearchIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full rounded-md border border-gray-200 bg-white py-1.5 pl-10 pr-3 leading-5 placeholder-gray-500 focus:border-gray-300 focus:outline-hidden focus:ring-blue-500 disabled:opacity-70 sm:text-sm"
|
||||
placeholder="Search courses..."
|
||||
className="block w-full rounded-lg border border-gray-200 bg-white py-3 pr-3 pl-10 leading-5 placeholder-gray-500 focus:border-gray-300 focus:ring-blue-500 focus:outline-hidden disabled:opacity-70 sm:text-sm"
|
||||
placeholder={placeholder || 'Search courses...'}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useId } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type QuestionProps = {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
@@ -8,13 +11,18 @@ type QuestionProps = {
|
||||
|
||||
function Question(props: QuestionProps) {
|
||||
const { label, placeholder, value, onChange, autoFocus = false } = props;
|
||||
const questionId = useId();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<label className="border-y bg-gray-100 px-4 py-2.5 text-sm font-medium text-gray-700">
|
||||
<label
|
||||
htmlFor={questionId}
|
||||
className="border-y bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
id={questionId}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[80px] w-full resize-none px-4 py-3 text-gray-700 placeholder:text-gray-400 focus:outline-hidden"
|
||||
value={value}
|
||||
@@ -31,10 +39,10 @@ type FineTuneCourseProps = {
|
||||
goal: string;
|
||||
customInstructions: string;
|
||||
|
||||
setHasFineTuneData: (hasMetadata: boolean) => void;
|
||||
setAbout: (about: string) => void;
|
||||
setGoal: (goal: string) => void;
|
||||
setCustomInstructions: (customInstructions: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FineTuneCourse(props: FineTuneCourseProps) {
|
||||
@@ -46,7 +54,7 @@ export function FineTuneCourse(props: FineTuneCourseProps) {
|
||||
setAbout,
|
||||
setGoal,
|
||||
setCustomInstructions,
|
||||
setHasFineTuneData,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
if (!hasFineTuneData) {
|
||||
@@ -54,7 +62,7 @@ export function FineTuneCourse(props: FineTuneCourseProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-0 flex flex-col">
|
||||
<div className={cn('mt-0 flex flex-col', className)}>
|
||||
<Question
|
||||
label="Tell us about yourself"
|
||||
placeholder="e.g. I am a frontend developer and have good knowledge of HTML, CSS, and JavaScript."
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { BookOpen, Loader2 } 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 {
|
||||
listUserAiCoursesOptions,
|
||||
type ListUserAiCoursesQuery,
|
||||
} from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AITutorHeader } from '../AITutor/AITutorHeader';
|
||||
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import { AICourseCard } from './AICourseCard';
|
||||
import { AICourseSearch } from './AICourseSearch';
|
||||
import { AILoadingState } from '../AITutor/AILoadingState';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
export function UserCoursesList() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
@@ -60,28 +58,8 @@ export function UserCoursesList() {
|
||||
}
|
||||
}, [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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isUserAuthenticated = isLoggedIn();
|
||||
const isAnyLoading = isUserAiCoursesLoading || isInitialLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -89,60 +67,78 @@ export function UserCoursesList() {
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
|
||||
)}
|
||||
|
||||
<AITutorHeader
|
||||
title="Your Courses"
|
||||
onUpgradeClick={() => setShowUpgradePopup(true)}
|
||||
>
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AITutorHeader>
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
disabled={isAnyLoading}
|
||||
/>
|
||||
|
||||
{(isUserAiCoursesLoading || isInitialLoading) && (
|
||||
<AILoadingState
|
||||
title="Loading your courses"
|
||||
subtitle="This may take a moment..."
|
||||
/>
|
||||
{isAnyLoading && (
|
||||
<p className="mb-4 flex flex-row items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading your courses...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isUserAiCoursesLoading && !isInitialLoading && courses.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<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>
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated
|
||||
? `You have generated ${userAiCourses?.totalCount} courses so far.`
|
||||
: 'Sign up or login to generate your first course. Takes 2s to do so.'}
|
||||
</p>
|
||||
|
||||
<Pagination
|
||||
totalCount={userAiCourses?.totalCount || 0}
|
||||
totalPages={userAiCourses?.totalPages || 0}
|
||||
currPage={Number(userAiCourses?.currPage || 1)}
|
||||
perPage={Number(userAiCourses?.perPage || 10)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isUserAuthenticated && !isAnyLoading && courses.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{courses.map((course) => (
|
||||
<AICourseCard key={course._id} course={course} />
|
||||
))}
|
||||
|
||||
{!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';
|
||||
}}
|
||||
/>
|
||||
<Pagination
|
||||
totalCount={userAiCourses?.totalCount || 0}
|
||||
totalPages={userAiCourses?.totalPages || 0}
|
||||
currPage={Number(userAiCourses?.currPage || 1)}
|
||||
perPage={Number(userAiCourses?.perPage || 10)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && courses.length === 0 && (
|
||||
<AITutorTallMessage
|
||||
title={
|
||||
isUserAuthenticated ? 'No courses found' : 'Sign up or login'
|
||||
}
|
||||
subtitle={
|
||||
isUserAuthenticated
|
||||
? "You haven't generated any courses yet."
|
||||
: 'Takes 2s to sign up and generate your first course.'
|
||||
}
|
||||
icon={BookOpen}
|
||||
buttonText={
|
||||
isUserAuthenticated
|
||||
? 'Create your first course'
|
||||
: 'Sign up or login'
|
||||
}
|
||||
onButtonClick={() => {
|
||||
if (isUserAuthenticated) {
|
||||
window.location.href = '/ai';
|
||||
} else {
|
||||
showLoginPopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user