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:
Arik ChakmaandKamran Ahmed authored and GitHub committed 2025-06-19 02:22:04 +01:00
1 parent 469f4ca530
commit 89932bc18d
56 files changed
+3959 -280

No files matched your search

+8 -3
View File
@@ -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}
+55 -32
View File
@@ -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();
}
}}
/>
)}
</>
)}
</>
);