From 21bba70d5369611fa635b9db26109cd750b5b42a Mon Sep 17 00:00:00 2001 From: Arik Chakma <arikchangma@gmail.com> Date: Fri, 14 Mar 2025 18:37:58 +0600 Subject: [PATCH] feat: ai course pagination --- .../GenerateCourse/AICourseCard.tsx | 4 +- .../GenerateCourse/AICourseSearch.tsx | 46 +++++++ .../GenerateCourse/UserCoursesList.tsx | 122 +++++++++++------- src/components/Pagination/Pagination.tsx | 14 +- src/queries/ai-course.ts | 31 ++++- 5 files changed, 157 insertions(+), 60 deletions(-) create mode 100644 src/components/GenerateCourse/AICourseSearch.tsx diff --git a/src/components/GenerateCourse/AICourseCard.tsx b/src/components/GenerateCourse/AICourseCard.tsx index 4048dd73f..329969ade 100644 --- a/src/components/GenerateCourse/AICourseCard.tsx +++ b/src/components/GenerateCourse/AICourseCard.tsx @@ -1,9 +1,9 @@ -import type { AICourseListItem } from '../../queries/ai-course'; +import type { AICourseWithLessonCount } from '../../queries/ai-course'; import type { DifficultyLevel } from './AICourse'; import { BookOpen } from 'lucide-react'; type AICourseCardProps = { - course: AICourseListItem; + course: AICourseWithLessonCount; }; export function AICourseCard(props: AICourseCardProps) { diff --git a/src/components/GenerateCourse/AICourseSearch.tsx b/src/components/GenerateCourse/AICourseSearch.tsx new file mode 100644 index 000000000..bd0f68a76 --- /dev/null +++ b/src/components/GenerateCourse/AICourseSearch.tsx @@ -0,0 +1,46 @@ +import { SearchIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useDebounceValue } from '../../hooks/use-debounce'; + +type AICourseSearchProps = { + value: string; + onChange: (value: string) => void; +}; + +export function AICourseSearch(props: AICourseSearchProps) { + const { value: defaultValue, onChange } = props; + + const [searchTerm, setSearchTerm] = useState(defaultValue); + const debouncedSearchTerm = useDebounceValue(searchTerm, 500); + + useEffect(() => { + setSearchTerm(defaultValue); + }, [defaultValue]); + + useEffect(() => { + if (debouncedSearchTerm && debouncedSearchTerm.length < 3) { + return; + } + + if (debouncedSearchTerm === defaultValue) { + return; + } + + onChange(debouncedSearchTerm); + }, [debouncedSearchTerm]); + + return ( + <div className="relative w-64 max-sm:hidden"> + <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-none focus:ring-blue-500 disabled:opacity-70 sm:text-sm" + placeholder="Search your courses..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + /> + </div> + ); +} diff --git a/src/components/GenerateCourse/UserCoursesList.tsx b/src/components/GenerateCourse/UserCoursesList.tsx index 92ecab6f7..a7f1e1bfb 100644 --- a/src/components/GenerateCourse/UserCoursesList.tsx +++ b/src/components/GenerateCourse/UserCoursesList.tsx @@ -2,24 +2,33 @@ import { useQuery } from '@tanstack/react-query'; import { getAiCourseLimitOptions, listUserAiCoursesOptions, + type ListUserAiCoursesQuery, } from '../../queries/ai-course'; import { queryClient } from '../../stores/query-client'; import { AICourseCard } from './AICourseCard'; import { useEffect, useState } from 'react'; -import { Gift, Loader2, Search, User2 } from 'lucide-react'; +import { Gift, Loader2, User2 } from 'lucide-react'; import { isLoggedIn } from '../../lib/jwt'; import { showLoginPopup } from '../../lib/popup'; import { cn } from '../../lib/classname'; import { useIsPaidUser } from '../../queries/billing'; import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { getUrlParams, setUrlParams, deleteUrlParam } from '../../lib/browser'; +import { AICourseSearch } from './AICourseSearch'; +import { Pagination } from '../Pagination/Pagination'; type UserCoursesListProps = {}; export function UserCoursesList(props: UserCoursesListProps) { - const [searchTerm, setSearchTerm] = useState(''); const [isInitialLoading, setIsInitialLoading] = useState(true); const [showUpgradePopup, setShowUpgradePopup] = useState(false); + const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({ + perPage: '10', + currPage: '1', + query: '', + }); + const { data: limits, isLoading: isLimitsLoading } = useQuery( getAiCourseLimitOptions(), queryClient, @@ -29,7 +38,7 @@ export function UserCoursesList(props: UserCoursesListProps) { const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser(); const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery( - listUserAiCoursesOptions(), + listUserAiCoursesOptions(pageState), queryClient, ); @@ -37,21 +46,31 @@ export function UserCoursesList(props: UserCoursesListProps) { setIsInitialLoading(false); }, [userAiCourses]); - const filteredCourses = userAiCourses?.filter((course) => { - if (!searchTerm.trim()) { - return true; - } + const courses = userAiCourses?.data ?? []; + const isAuthenticated = isLoggedIn(); + const limitUsedPercentage = Math.round((used / limit) * 100); - const searchLower = searchTerm.toLowerCase(); + useEffect(() => { + const queryParams = getUrlParams(); - return ( - course.title.toLowerCase().includes(searchLower) || - course.keyword.toLowerCase().includes(searchLower) - ); - }); + setPageState({ + ...pageState, + currPage: queryParams?.p || '1', + query: queryParams?.q || '', + }); + }, []); - const isAuthenticated = isLoggedIn(); - const limitUsedPercentage = Math.round((used / limit) * 100); + useEffect(() => { + if (pageState?.currPage !== '1' || pageState?.query !== '') { + setUrlParams({ + p: pageState?.currPage || '1', + q: pageState?.query || '', + }); + } else { + deleteUrlParam('p'); + deleteUrlParam('q'); + } + }, [pageState]); return ( <> @@ -69,9 +88,9 @@ export function UserCoursesList(props: UserCoursesListProps) { {used > 0 && limit > 0 && !isPaidUserLoading && ( <div className={cn( - 'flex items-center gap-2 opacity-0 transition-opacity', + 'pointer-events-none flex items-center gap-2 opacity-0 transition-opacity', { - 'opacity-100': !isPaidUser, + 'pointer-events-auto opacity-100': !isPaidUser, }, )} > @@ -95,18 +114,16 @@ export function UserCoursesList(props: UserCoursesListProps) { </div> )} - <div className={cn('relative w-64 max-sm:hidden', {})}> - <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> - <Search 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 transition-all focus:border-gray-300 focus:outline-none focus:ring-blue-500 disabled:opacity-70 sm:text-sm" - placeholder="Search your courses..." - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - /> - </div> + <AICourseSearch + value={pageState?.query || ''} + onChange={(value) => { + setPageState({ + ...pageState, + query: value, + currPage: '1', + }); + }} + /> </div> </div> @@ -127,15 +144,13 @@ export function UserCoursesList(props: UserCoursesListProps) { </div> )} - {!isUserAiCoursesLoading && - !isInitialLoading && - userAiCourses?.length === 0 && ( - <div className="flex min-h-[152px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> - <p className="text-sm text-gray-600"> - You haven't generated any courses yet. - </p> - </div> - )} + {!isUserAiCoursesLoading && !isInitialLoading && courses.length === 0 && ( + <div className="flex min-h-[152px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> + <p className="text-sm text-gray-600"> + You haven't generated any courses yet. + </p> + </div> + )} {(isUserAiCoursesLoading || isInitialLoading) && ( <div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4"> @@ -147,19 +162,28 @@ export function UserCoursesList(props: UserCoursesListProps) { </div> )} - {!isUserAiCoursesLoading && - filteredCourses && - filteredCourses.length > 0 && ( - <div className="flex flex-col gap-2"> - {filteredCourses.map((course) => ( - <AICourseCard key={course._id} course={course} /> - ))} - </div> - )} + {!isUserAiCoursesLoading && courses && courses.length > 0 && ( + <div className="flex flex-col gap-2"> + {courses.map((course) => ( + <AICourseCard key={course._id} course={course} /> + ))} + + <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> + )} {!isUserAiCoursesLoading && - (userAiCourses?.length || 0 > 0) && - filteredCourses?.length === 0 && ( + (userAiCourses?.data?.length || 0 > 0) && + courses.length === 0 && ( <div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4"> <p className="text-sm text-gray-600"> No courses match your search. diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx index 86f3ac4de..3f4c8d604 100644 --- a/src/components/Pagination/Pagination.tsx +++ b/src/components/Pagination/Pagination.tsx @@ -11,6 +11,7 @@ type PaginationProps = { totalCount: number; isDisabled?: boolean; onPageChange: (page: number) => void; + className?: string; }; export function Pagination(props: PaginationProps) { @@ -22,6 +23,7 @@ export function Pagination(props: PaginationProps) { currPage, perPage, isDisabled = false, + className, } = props; if (!totalPages || totalPages === 1) { @@ -32,10 +34,14 @@ export function Pagination(props: PaginationProps) { return ( <div - className={cn('flex items-center', { - 'justify-between': variant === 'default', - 'justify-start': variant === 'minimal', - })} + className={cn( + 'flex items-center', + { + 'justify-between': variant === 'default', + 'justify-start': variant === 'minimal', + }, + className, + )} > <div className="flex items-center gap-1 text-xs font-medium"> <button diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts index c95c77154..d7c86333a 100644 --- a/src/queries/ai-course.ts +++ b/src/queries/ai-course.ts @@ -75,17 +75,38 @@ export function getAiCourseLimitOptions() { }); } -export type AICourseListItem = AICourseDocument & { +export type ListUserAiCoursesQuery = { + perPage?: string; + currPage?: string; + query?: string; +}; + +export type AICourseWithLessonCount = AICourseDocument & { lessonCount: number; }; -type ListUserAiCoursesResponse = AICourseListItem[]; +type ListUserAiCoursesResponse = { + data: AICourseWithLessonCount[]; + totalCount: number; + totalPages: number; + currPage: number; + perPage: number; +}; -export function listUserAiCoursesOptions() { +export function listUserAiCoursesOptions( + params: ListUserAiCoursesQuery = { + perPage: '10', + currPage: '1', + query: '', + }, +) { return { - queryKey: ['user-ai-courses'], + queryKey: ['user-ai-courses', params], queryFn: () => { - return httpGet<ListUserAiCoursesResponse>(`/v1-list-user-ai-courses`); + return httpGet<ListUserAiCoursesResponse>( + `/v1-list-user-ai-courses`, + params, + ); }, enabled: !!isLoggedIn(), };