1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-25 00:21:28 +02:00

UI improvements for ai library

This commit is contained in:
Kamran Ahmed
2025-06-18 22:51:19 +01:00
parent 256518b68c
commit 0503b64cba
6 changed files with 153 additions and 142 deletions

View File

@@ -1,4 +1,7 @@
import { useState } from 'react';
import { AITutorHeader } from '../AITutor/AITutorHeader';
import { AITutorLayout } from '../AITutor/AITutorLayout'; import { AITutorLayout } from '../AITutor/AITutorLayout';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { LibraryTabs } from '../Library/LibraryTab'; import { LibraryTabs } from '../Library/LibraryTab';
type AILibraryLayoutProps = { type AILibraryLayoutProps = {
@@ -9,9 +12,20 @@ type AILibraryLayoutProps = {
export function AILibraryLayout(props: AILibraryLayoutProps) { export function AILibraryLayout(props: AILibraryLayoutProps) {
const { activeTab, children } = props; const { activeTab, children } = props;
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
return ( return (
<AITutorLayout activeTab="library"> <AITutorLayout activeTab="library">
<div className="mx-auto w-full max-w-4xl p-2"> {showUpgradePopup && (
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<div className="mx-auto flex w-full max-w-6xl flex-grow flex-col p-2">
<AITutorHeader
title="Library"
subtitle="Explore your AI-generated guides and courses"
onUpgradeClick={() => setShowUpgradePopup(true)}
/>
<LibraryTabs activeTab={activeTab} /> <LibraryTabs activeTab={activeTab} />
{children} {children}
</div> </div>

View File

@@ -3,15 +3,17 @@ import { AITutorLimits } from './AITutorLimits';
import { getAiCourseLimitOptions } from '../../queries/ai-course'; import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client'; import { queryClient } from '../../stores/query-client';
import { useIsPaidUser } from '../../queries/billing'; import { useIsPaidUser } from '../../queries/billing';
import { PlusIcon } from 'lucide-react';
type AITutorHeaderProps = { type AITutorHeaderProps = {
title: string; title: string;
subtitle?: string;
onUpgradeClick: () => void; onUpgradeClick: () => void;
children?: React.ReactNode; children?: React.ReactNode;
}; };
export function AITutorHeader(props: AITutorHeaderProps) { export function AITutorHeader(props: AITutorHeaderProps) {
const { title, onUpgradeClick, children } = props; const { title, subtitle, onUpgradeClick, children } = props;
const { data: limits } = useQuery(getAiCourseLimitOptions(), queryClient); const { data: limits } = useQuery(getAiCourseLimitOptions(), queryClient);
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser(); const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
@@ -20,20 +22,29 @@ export function AITutorHeader(props: AITutorHeaderProps) {
return ( return (
<div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1"> <div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1">
<div className="flex items-center gap-2"> <div className="flex w-full flex-row items-center justify-between gap-2">
<h2 className="relative flex-shrink-0 top-0 lg:top-1 text-lg font-semibold">{title}</h2> <div className="gap-2">
</div> <h2 className="relative top-0 mb-4 flex-shrink-0 text-3xl font-semibold lg:top-1">
{title}
<div className="flex items-center gap-2"> </h2>
<AITutorLimits {subtitle && <p className="mb-4 text-sm text-gray-500">{subtitle}</p>}
used={used} </div>
limit={limit} <div className="flex flex-row items-center gap-2">
isPaidUser={isPaidUser} <AITutorLimits
isPaidUserLoading={isPaidUserLoading} used={used}
onUpgradeClick={onUpgradeClick} limit={limit}
/> isPaidUser={isPaidUser}
isPaidUserLoading={isPaidUserLoading}
{children} onUpgradeClick={onUpgradeClick}
/>
<a
href="/ai"
className="flex flex-row items-center gap-2 rounded-lg bg-black px-4 py-2 text-sm font-medium text-white"
>
<PlusIcon className="h-4 w-4" />
New
</a>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -28,62 +28,50 @@ export function AICourseCard(props: AICourseCardProps) {
const updatedAgo = getRelativeTimeString(course?.updatedAt); const updatedAgo = getRelativeTimeString(course?.updatedAt);
return ( return (
<div className="relative flex flex-grow flex-col"> <div className="relative flex flex-grow">
<a <a
href={`/ai/${course.slug}`} href={`/ai/${course.slug}`}
className="hover:border-gray-3 00 group relative flex h-full min-h-[300px] 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="group relative flex h-full w-full flex-col 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:flex-row sm:items-center sm:gap-4"
> >
<div className="flex items-center justify-between"> {/* Title and difficulty section */}
<span <div className="min-w-0 flex-1">
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`} <div className="mb-1 flex items-center gap-2">
> <span
{course.difficulty} className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
</span> >
{course.difficulty}
</span>
</div>
<h3 className="line-clamp-2 text-base font-semibold text-balance text-gray-900">
{course.title}
</h3>
</div> </div>
<h3 className="my-2 text-base font-semibold text-balance text-gray-900"> {/* Course stats section */}
{course.title} <div className="flex mt-7 items-center gap-4 sm:gap-4">
</h3> <div className="hidden items-center text-xs text-gray-600 sm:flex">
<div className="flex items-center gap-2 pt-2">
<div className="flex items-center text-xs text-gray-600">
<BookOpen className="mr-1 h-3.5 w-3.5" /> <BookOpen className="mr-1 h-3.5 w-3.5" />
<span>{modulesCount} modules</span> <span>{modulesCount} modules</span>
</div> </div>
<span className="text-xs text-gray-600"></span> <div className="flex items-center gap-2 text-xs text-gray-600">
<div className="flex items-center">
<div className="flex items-center text-xs text-gray-600"> <BookOpen className="mr-1 h-3.5 w-3.5" />
<BookOpen className="mr-1 h-3.5 w-3.5" /> <span>{totalTopics} lessons</span>
<span>{totalTopics} lessons</span>
</div>
</div>
{showProgress && totalTopics > 0 && (
<div className="mt-auto">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-600">Progress</span>
<span className="text-xs font-medium text-gray-700">
{progressPercentage}%
</span>
</div> </div>
<div className="mt-2.5 flex items-center"> {showProgress && totalTopics > 0 && (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-200"> <>
<div <span className="hidden text-gray-400 sm:inline"></span>
className="h-full rounded-full bg-blue-600" <div className="flex items-center">
style={{ width: `${progressPercentage}%` }} <span className="flex items-center text-xs font-medium text-gray-700">
/> {progressPercentage}% complete
</div> </span>
</div> </div>
</>
)}
</div> </div>
)}
<div className="mt-4 flex items-center justify-between gap-2">
<span className="text-xs text-gray-600">
Last updated {updatedAgo}
</span>
</div> </div>
</a> </a>

View File

@@ -6,10 +6,11 @@ type AICourseSearchProps = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
placeholder?: string; placeholder?: string;
disabled?: boolean;
}; };
export function AICourseSearch(props: AICourseSearchProps) { export function AICourseSearch(props: AICourseSearchProps) {
const { value: defaultValue, onChange, placeholder } = props; const { value: defaultValue, onChange, placeholder, disabled } = props;
const [searchTerm, setSearchTerm] = useState(defaultValue); const [searchTerm, setSearchTerm] = useState(defaultValue);
const debouncedSearchTerm = useDebounceValue(searchTerm, 500); const debouncedSearchTerm = useDebounceValue(searchTerm, 500);
@@ -31,16 +32,17 @@ export function AICourseSearch(props: AICourseSearchProps) {
}, [debouncedSearchTerm]); }, [debouncedSearchTerm]);
return ( 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"> <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" /> <SearchIcon className="h-4 w-4 text-gray-400" />
</div> </div>
<input <input
type="text" type="text"
className="block w-full rounded-md border border-gray-200 bg-white py-1.5 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" 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...'} placeholder={placeholder || 'Search courses...'}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
disabled={disabled}
/> />
</div> </div>
); );

View File

@@ -1,21 +1,19 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { BookOpen } from 'lucide-react'; import { BookOpen, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser'; import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { import {
listUserAiCoursesOptions, listUserAiCoursesOptions,
type ListUserAiCoursesQuery, type ListUserAiCoursesQuery,
} from '../../queries/ai-course'; } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client'; import { queryClient } from '../../stores/query-client';
import { AITutorHeader } from '../AITutor/AITutorHeader';
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage'; import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { Pagination } from '../Pagination/Pagination'; import { Pagination } from '../Pagination/Pagination';
import { AICourseCard } from './AICourseCard'; import { AICourseCard } from './AICourseCard';
import { AICourseSearch } from './AICourseSearch'; import { AICourseSearch } from './AICourseSearch';
import { AILoadingState } from '../AITutor/AILoadingState'; import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
export function UserCoursesList() { export function UserCoursesList() {
const [isInitialLoading, setIsInitialLoading] = useState(true); const [isInitialLoading, setIsInitialLoading] = useState(true);
@@ -60,28 +58,8 @@ export function UserCoursesList() {
} }
}, [pageState]); }, [pageState]);
if (isUserAiCoursesLoading || isInitialLoading) { const isUserAuthenticated = isLoggedIn();
return ( const isAnyLoading = isUserAiCoursesLoading || isInitialLoading;
<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();
}}
/>
);
}
return ( return (
<> <>
@@ -89,60 +67,76 @@ export function UserCoursesList() {
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} /> <UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)} )}
<AITutorHeader <AICourseSearch
title="Your Courses" value={pageState?.query || ''}
onUpgradeClick={() => setShowUpgradePopup(true)} onChange={(value) => {
> setPageState({
<AICourseSearch ...pageState,
value={pageState?.query || ''} query: value,
onChange={(value) => { currPage: '1',
setPageState({ });
...pageState, }}
query: value, disabled={isAnyLoading}
currPage: '1', />
});
}}
/>
</AITutorHeader>
{(isUserAiCoursesLoading || isInitialLoading) && ( {isAnyLoading && (
<AILoadingState <p className="mb-4 flex flex-row items-center gap-2 text-sm text-gray-500">
title="Loading your courses" <Loader2 className="h-4 w-4 animate-spin" />
subtitle="This may take a moment..." Loading your courses...
/> </p>
)} )}
{!isUserAiCoursesLoading && !isInitialLoading && courses.length > 0 && ( {!isAnyLoading && (
<div className="flex flex-col gap-2"> <>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3"> <p className="mb-4 text-sm text-gray-500">
{courses.map((course) => ( You have generated {userAiCourses?.totalCount} courses so far.
<AICourseCard key={course._id} course={course} /> </p>
))}
</div>
<Pagination {isUserAuthenticated && !isAnyLoading && courses.length > 0 && (
totalCount={userAiCourses?.totalCount || 0} <div className="flex flex-col gap-2">
totalPages={userAiCourses?.totalPages || 0} {courses.map((course) => (
currPage={Number(userAiCourses?.currPage || 1)} <AICourseCard key={course._id} course={course} />
perPage={Number(userAiCourses?.perPage || 10)} ))}
onPageChange={(page) => {
setPageState({ ...pageState, currPage: String(page) });
}}
className="rounded-lg border border-gray-200 bg-white p-4"
/>
</div>
)}
{!isUserAiCoursesLoading && !isInitialLoading && courses.length === 0 && ( <Pagination
<AITutorTallMessage totalCount={userAiCourses?.totalCount || 0}
title="No courses found" totalPages={userAiCourses?.totalPages || 0}
subtitle="You haven't generated any courses yet." currPage={Number(userAiCourses?.currPage || 1)}
icon={BookOpen} perPage={Number(userAiCourses?.perPage || 10)}
buttonText="Create your first course" onPageChange={(page) => {
onButtonClick={() => { setPageState({ ...pageState, currPage: String(page) });
window.location.href = '/ai'; }}
}} 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();
}
}}
/>
)}
</>
)} )}
</> </>
); );

View File

@@ -9,7 +9,7 @@ export function LibraryTabs(props: LibraryTabsProps) {
const { activeTab } = props; const { activeTab } = props;
return ( return (
<div className="mb-6 flex gap-2"> <div className="mb-6 flex gap-2 border-b border-gray-300">
<LibraryTabButton <LibraryTabButton
isActive={activeTab === 'courses'} isActive={activeTab === 'courses'}
icon={BookOpen} icon={BookOpen}
@@ -40,8 +40,10 @@ function LibraryTabButton(props: LibraryTabButtonProps) {
<a <a
href={href} href={href}
className={cn( className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-sm font-medium', 'flex items-center gap-1 rounded-t-md px-4 py-2 text-sm font-medium',
isActive ? 'bg-gray-200' : 'bg-gray-100', isActive
? 'bg-gray-300'
: 'bg-gray-100 transition-colors hover:bg-gray-200',
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />