1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-25 08:35:42 +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 { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { LibraryTabs } from '../Library/LibraryTab';
type AILibraryLayoutProps = {
@@ -9,9 +12,20 @@ type AILibraryLayoutProps = {
export function AILibraryLayout(props: AILibraryLayoutProps) {
const { activeTab, children } = props;
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
return (
<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} />
{children}
</div>

View File

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

View File

@@ -28,12 +28,14 @@ export function AICourseCard(props: AICourseCardProps) {
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-[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 */}
<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}`}
>
@@ -41,49 +43,35 @@ export function AICourseCard(props: AICourseCardProps) {
</span>
</div>
<h3 className="my-2 text-base font-semibold text-balance text-gray-900">
<h3 className="line-clamp-2 text-base font-semibold text-balance text-gray-900">
{course.title}
</h3>
</div>
<div className="flex items-center gap-2 pt-2">
<div className="flex items-center text-xs text-gray-600">
{/* Course stats section */}
<div className="flex mt-7 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>
<span className="text-xs text-gray-600"></span>
<div className="flex items-center text-xs text-gray-600">
<div className="flex items-center gap-2 text-xs text-gray-600">
<div className="flex items-center">
<BookOpen className="mr-1 h-3.5 w-3.5" />
<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 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 className="mt-2.5 flex items-center">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-blue-600"
style={{ width: `${progressPercentage}%` }}
/>
</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>

View File

@@ -6,10 +6,11 @@ type AICourseSearchProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
};
export function AICourseSearch(props: AICourseSearchProps) {
const { value: defaultValue, onChange, placeholder } = props;
const { value: defaultValue, onChange, placeholder, disabled } = props;
const [searchTerm, setSearchTerm] = useState(defaultValue);
const debouncedSearchTerm = useDebounceValue(searchTerm, 500);
@@ -31,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 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...'}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={disabled}
/>
</div>
);

View File

@@ -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,10 +67,6 @@ export function UserCoursesList() {
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<AITutorHeader
title="Your Courses"
onUpgradeClick={() => setShowUpgradePopup(true)}
>
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
@@ -102,23 +76,27 @@ export function UserCoursesList() {
currPage: '1',
});
}}
disabled={isAnyLoading}
/>
</AITutorHeader>
{(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 && (
{!isAnyLoading && (
<>
<p className="mb-4 text-sm text-gray-500">
You have generated {userAiCourses?.totalCount} courses so far.
</p>
{isUserAuthenticated && !isAnyLoading && 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>
<Pagination
totalCount={userAiCourses?.totalCount || 0}
@@ -133,17 +111,33 @@ export function UserCoursesList() {
</div>
)}
{!isUserAiCoursesLoading && !isInitialLoading && courses.length === 0 && (
{!isAnyLoading && courses.length === 0 && (
<AITutorTallMessage
title="No courses found"
subtitle="You haven't generated any courses yet."
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="Create your first course"
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;
return (
<div className="mb-6 flex gap-2">
<div className="mb-6 flex gap-2 border-b border-gray-300">
<LibraryTabButton
isActive={activeTab === 'courses'}
icon={BookOpen}
@@ -40,8 +40,10 @@ function LibraryTabButton(props: LibraryTabButtonProps) {
<a
href={href}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-sm font-medium',
isActive ? 'bg-gray-200' : 'bg-gray-100',
'flex items-center gap-1 rounded-t-md px-4 py-2 text-sm font-medium',
isActive
? 'bg-gray-300'
: 'bg-gray-100 transition-colors hover:bg-gray-200',
)}
>
<Icon className="h-4 w-4" />