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:
@@ -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>
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
|
@@ -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>
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
|
@@ -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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@@ -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" />
|
||||||
|
Reference in New Issue
Block a user