diff --git a/src/components/ExploreAIRoadmap/AIRoadmapsList.tsx b/src/components/ExploreAIRoadmap/AIRoadmapsList.tsx new file mode 100644 index 000000000..9f65ff18b --- /dev/null +++ b/src/components/ExploreAIRoadmap/AIRoadmapsList.tsx @@ -0,0 +1,57 @@ +import type { AIRoadmapDocument } from './ExploreAIRoadmap.tsx'; +import { Eye } from 'lucide-react'; +import { getRelativeTimeString } from '../../lib/date.ts'; + +export type ExploreRoadmapsResponse = { + data: AIRoadmapDocument[]; + totalCount: number; + totalPages: number; + currPage: number; + perPage: number; +}; + +type AIRoadmapsListProps = { + response: ExploreRoadmapsResponse | null; +}; + +export function AIRoadmapsList(props: AIRoadmapsListProps) { + const { response } = props; + + if (!response) { + return null; + } + + const roadmaps = response.data || []; + + return ( + + ); +} diff --git a/src/components/ExploreAIRoadmap/EmptyRoadmaps.tsx b/src/components/ExploreAIRoadmap/EmptyRoadmaps.tsx new file mode 100644 index 000000000..cd175c506 --- /dev/null +++ b/src/components/ExploreAIRoadmap/EmptyRoadmaps.tsx @@ -0,0 +1,31 @@ +import { Map, Wand2 } from 'lucide-react'; + +export function EmptyRoadmaps() { + return ( +
+ +

+ No Roadmaps Found +

+

+ Try searching for something else or create a new roadmap with AI. +

+
+ + + Create one with AI + + + + Visit Official Roadmaps + +
+
+ ); +} diff --git a/src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx b/src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx index 151e7da72..a5563069a 100644 --- a/src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx +++ b/src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx @@ -1,9 +1,19 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useToast } from '../../hooks/use-toast'; import { httpGet } from '../../lib/http'; -import { getRelativeTimeString } from '../../lib/date'; -import { Eye, Loader2, RefreshCcw } from 'lucide-react'; import { AIRoadmapAlert } from '../GenerateRoadmap/AIRoadmapAlert.tsx'; +import { ExploreAISearch } from './ExploreAISearch.tsx'; +import { ExploreAISorting, type SortByValues } from './ExploreAISorting.tsx'; +import { + deleteUrlParam, + getUrlParams, + setUrlParams, +} from '../../lib/browser.ts'; +import { Pagination } from '../Pagination/Pagination.tsx'; +import { LoadingRoadmaps } from './LoadingRoadmaps.tsx'; +import { EmptyRoadmaps } from './EmptyRoadmaps.tsx'; +import { AIRoadmapsList } from './AIRoadmapsList.tsx'; +import { currentRoadmap } from '../../stores/roadmap.ts'; export interface AIRoadmapDocument { _id?: string; @@ -23,127 +33,153 @@ type ExploreRoadmapsResponse = { perPage: number; }; +type QueryParams = { + q?: string; + s?: SortByValues; + p?: string; +}; + +type PageState = { + searchTerm: string; + sortBy: SortByValues; + currentPage: number; +}; + export function ExploreAIRoadmap() { const toast = useToast(); - const [isLoading, setIsLoading] = useState(true); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [roadmaps, setRoadmaps] = useState([]); - const [currPage, setCurrPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - - const loadAIRoadmaps = useCallback( - async (currPage: number) => { - const { response, error } = await httpGet( - `${import.meta.env.PUBLIC_API_URL}/v1-list-ai-roadmaps`, - { - currPage, - }, - ); - - if (error || !response) { - toast.error(error?.message || 'Something went wrong'); - return; - } - - const newRoadmaps = [...roadmaps, ...response.data]; - if ( - JSON.stringify(roadmaps) === JSON.stringify(response.data) || - JSON.stringify(roadmaps) === JSON.stringify(newRoadmaps) - ) { - return; - } - - setRoadmaps(newRoadmaps); - setCurrPage(response.currPage); - setTotalPages(response.totalPages); - }, - [currPage, roadmaps], - ); + const [pageState, setPageState] = useState({ + searchTerm: '', + sortBy: 'createdAt', + currentPage: 0, + }); useEffect(() => { - loadAIRoadmaps(currPage).finally(() => { - setIsLoading(false); + const queryParams = getUrlParams() as QueryParams; + + setPageState({ + searchTerm: queryParams.q || '', + sortBy: queryParams.s || 'createdAt', + currentPage: +(queryParams.p || '1'), }); }, []); - const hasMorePages = currPage < totalPages; + useEffect(() => { + setIsLoading(true); + if (!pageState.currentPage) { + return; + } + + // only set the URL params if the user modified anything + if ( + pageState.currentPage !== 1 || + pageState.searchTerm !== '' || + pageState.sortBy !== 'createdAt' + ) { + setUrlParams({ + q: pageState.searchTerm, + s: pageState.sortBy, + p: String(pageState.currentPage), + }); + } else { + deleteUrlParam('q'); + deleteUrlParam('s'); + deleteUrlParam('p'); + } + + loadAIRoadmaps( + pageState.currentPage, + pageState.searchTerm, + pageState.sortBy, + ).finally(() => { + setIsLoading(false); + }); + }, [pageState]); + + const [isLoading, setIsLoading] = useState(true); + const [roadmapsResponse, setRoadmapsResponse] = + useState(null); + + const loadAIRoadmaps = async ( + currPage: number = 1, + searchTerm: string = '', + sortBy: SortByValues = 'createdAt', + ) => { + const { response, error } = await httpGet( + `${import.meta.env.PUBLIC_API_URL}/v1-list-ai-roadmaps`, + { + currPage, + ...(searchTerm && { term: searchTerm }), + ...(sortBy && { sortBy }), + }, + ); + + if (error || !response) { + toast.error(error?.message || 'Something went wrong'); + return; + } + + setRoadmapsResponse(response); + }; + + const roadmaps = roadmapsResponse?.data || []; + + const loadingIndicator = isLoading && ; + const emptyRoadmaps = !isLoading && roadmaps.length === 0 && ( + + ); + + const roadmapsList = !isLoading && roadmaps.length > 0 && ( + <> + + { + setPageState({ + ...pageState, + currentPage: page, + }); + }} + /> + + ); return (
-
- + + +
+ { + setPageState({ + ...pageState, + searchTerm: term, + currentPage: 1, + }); + }} + /> + + { + setPageState({ + ...pageState, + sortBy, + currentPage: 1, + }); + }} + />
- {isLoading ? ( -
    - {new Array(21).fill(0).map((_, index) => ( -
  • - ))} -
- ) : ( -
- {roadmaps?.length === 0 ? ( -
No roadmaps found
- ) : ( - <> - - {hasMorePages && ( -
- -
- )} - - )} -
- )} + {loadingIndicator} + {emptyRoadmaps} + {roadmapsList}
); } diff --git a/src/components/ExploreAIRoadmap/ExploreAISearch.tsx b/src/components/ExploreAIRoadmap/ExploreAISearch.tsx new file mode 100644 index 000000000..3c01718a9 --- /dev/null +++ b/src/components/ExploreAIRoadmap/ExploreAISearch.tsx @@ -0,0 +1,58 @@ +import { Search } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useDebounceValue } from '../../hooks/use-debounce.ts'; +import { Spinner } from '../ReactIcons/Spinner.tsx'; + +type ExploreAISearchProps = { + value: string; + isLoading: boolean; + onSubmit: (search: string) => void; +}; + +export function ExploreAISearch(props: ExploreAISearchProps) { + const { onSubmit, isLoading = false, value: defaultValue } = props; + + const [term, setTerm] = useState(defaultValue); + const debouncedTerm = useDebounceValue(term, 500); + + useEffect(() => { + setTerm(defaultValue); + }, [defaultValue]); + + useEffect(() => { + if (debouncedTerm && debouncedTerm.length < 3) { + return; + } + + if (debouncedTerm === defaultValue) { + return; + } + + onSubmit(debouncedTerm); + }, [debouncedTerm]); + + return ( +
+ + setTerm(e.target.value)} + /> + {isLoading && ( + + + + )} +
+ ); +} diff --git a/src/components/ExploreAIRoadmap/ExploreAISorting.tsx b/src/components/ExploreAIRoadmap/ExploreAISorting.tsx new file mode 100644 index 000000000..47e11f0c6 --- /dev/null +++ b/src/components/ExploreAIRoadmap/ExploreAISorting.tsx @@ -0,0 +1,73 @@ +import { ArrowDownWideNarrow, Check, ChevronDown } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useOutsideClick } from '../../hooks/use-outside-click'; + +export type SortByValues = 'viewCount' | 'createdAt' | '-createdAt'; +const sortingLabels: { label: string; value: SortByValues }[] = [ + { + label: 'Most Viewed', + value: 'viewCount', + }, + { + label: 'Newest', + value: 'createdAt', + }, + { + label: 'Oldest', + value: '-createdAt', + }, +]; + +type ExploreAISortingProps = { + sortBy: SortByValues; + onSortChange: (sortBy: SortByValues) => void; +}; + +export function ExploreAISorting(props: ExploreAISortingProps) { + const { sortBy, onSortChange } = props; + + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const selectedValue = sortingLabels.find((item) => item.value === sortBy); + + useOutsideClick(dropdownRef, () => { + setIsOpen(false); + }); + + return ( +
+ + + {isOpen && ( +
+ {sortingLabels.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/ExploreAIRoadmap/LoadingRoadmaps.tsx b/src/components/ExploreAIRoadmap/LoadingRoadmaps.tsx new file mode 100644 index 000000000..b39d5b79c --- /dev/null +++ b/src/components/ExploreAIRoadmap/LoadingRoadmaps.tsx @@ -0,0 +1,12 @@ +export function LoadingRoadmaps() { + return ( +
    + {new Array(21).fill(0).map((_, index) => ( +
  • + ))} +
+ ); +} diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.tsx b/src/components/GenerateRoadmap/GenerateRoadmap.tsx index 875d36905..7d92323d6 100644 --- a/src/components/GenerateRoadmap/GenerateRoadmap.tsx +++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx @@ -314,7 +314,7 @@ export function GenerateRoadmap() { data, }); - setRoadmapTerm(title); + setRoadmapTerm(term); setGeneratedRoadmapContent(data); visitAIRoadmap(roadmapId); }; diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 000000000..86f3ac4de --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,101 @@ +import { usePagination } from '../../hooks/use-pagination.ts'; +import { MoreHorizontal } from 'lucide-react'; +import { cn } from '../../lib/classname.ts'; +import { formatCommaNumber } from '../../lib/number.ts'; + +type PaginationProps = { + variant?: 'minimal' | 'default'; + totalPages: number; + currPage: number; + perPage: number; + totalCount: number; + isDisabled?: boolean; + onPageChange: (page: number) => void; +}; + +export function Pagination(props: PaginationProps) { + const { + variant = 'default', + onPageChange, + totalCount, + totalPages, + currPage, + perPage, + isDisabled = false, + } = props; + + if (!totalPages || totalPages === 1) { + return null; + } + + const pages = usePagination(currPage, totalPages, 5); + + return ( +
+
+ + {variant === 'default' && ( + <> + {pages.map((page, counter) => { + if (page === 'more') { + return ( + + + + ); + } + + return ( + + ); + })} + + )} + +
+ + Showing {formatCommaNumber((currPage - 1) * perPage)} to{' '} + {formatCommaNumber((currPage - 1) * perPage + perPage)} of{' '} + {formatCommaNumber(totalCount)} entries + +
+ ); +} diff --git a/src/hooks/use-pagination.ts b/src/hooks/use-pagination.ts new file mode 100644 index 000000000..3df4ff527 --- /dev/null +++ b/src/hooks/use-pagination.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; + +export function usePagination( + currentPage: number, + totalPages: number, + maxPagesToShow: number, +) { + return useMemo(() => { + const pages: Array = []; + const half = Math.floor(maxPagesToShow / 2); + const start = Math.max(1, currentPage - half); + const end = Math.min(totalPages, currentPage + half); + + if (start > 1) { + pages.push(1); + } + + if (start > 2) { + pages.push('more'); + } + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (end < totalPages - 1) { + pages.push('more'); + } + + if (end < totalPages) { + pages.push(totalPages); + } + + return pages; + }, [currentPage, totalPages, maxPagesToShow]); +} diff --git a/src/lib/number.ts b/src/lib/number.ts new file mode 100644 index 000000000..6527e13c8 --- /dev/null +++ b/src/lib/number.ts @@ -0,0 +1,7 @@ +export const formatter = Intl.NumberFormat('en-US', { + useGrouping: true, +}); + +export function formatCommaNumber(number: number): string { + return formatter.format(number); +} diff --git a/src/pages/ai/explore.astro b/src/pages/ai/explore.astro index c501f613f..052e09463 100644 --- a/src/pages/ai/explore.astro +++ b/src/pages/ai/explore.astro @@ -6,5 +6,4 @@ import AccountLayout from '../../layouts/AccountLayout.astro'; -