feat: add search on the AI explore page (#5383)
* fix: type errors * chore: implement roadmap pagination * wip * wip: merge conflicts * wip: add search * Add pagination * Refactor AI search roadmaps --------- Co-authored-by: Arik Chakma <arikchangma@gmail.com>pull/5385/head
parent
6e6489bc4c
commit
812a39154c
11 changed files with 523 additions and 113 deletions
@ -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 ( |
||||||
|
<ul className="mb-4 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> |
||||||
|
{roadmaps.map((roadmap) => { |
||||||
|
const roadmapLink = `/ai?id=${roadmap._id}`; |
||||||
|
return ( |
||||||
|
<a |
||||||
|
key={roadmap._id} |
||||||
|
href={roadmapLink} |
||||||
|
className="flex min-h-[95px] flex-col rounded-md border transition-colors hover:bg-gray-100" |
||||||
|
target={'_blank'} |
||||||
|
> |
||||||
|
<h2 className="flex-grow px-2.5 py-2.5 text-base font-medium leading-tight"> |
||||||
|
{roadmap.title} |
||||||
|
</h2> |
||||||
|
<div className="flex items-center justify-between gap-2 px-2.5 py-2"> |
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gray-400"> |
||||||
|
<Eye size={15} className="inline-block" /> |
||||||
|
{Intl.NumberFormat('en-US', { |
||||||
|
notation: 'compact', |
||||||
|
}).format(roadmap.viewCount)}{' '} |
||||||
|
views |
||||||
|
</span> |
||||||
|
<span className="flex items-center gap-1.5 text-xs text-gray-400"> |
||||||
|
{getRelativeTimeString(String(roadmap?.createdAt))} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
); |
||||||
|
})} |
||||||
|
</ul> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,31 @@ |
|||||||
|
import { Map, Wand2 } from 'lucide-react'; |
||||||
|
|
||||||
|
export function EmptyRoadmaps() { |
||||||
|
return ( |
||||||
|
<div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl border px-5 py-3 sm:px-0 sm:py-20"> |
||||||
|
<Wand2 className="mb-4 h-8 w-8 opacity-10 sm:h-14 sm:w-14" /> |
||||||
|
<h2 className="mb-1 text-lg font-semibold sm:text-xl"> |
||||||
|
No Roadmaps Found |
||||||
|
</h2> |
||||||
|
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm"> |
||||||
|
Try searching for something else or create a new roadmap with AI. |
||||||
|
</p> |
||||||
|
<div className="flex flex-col items-center gap-1 sm:flex-row sm:gap-1.5"> |
||||||
|
<a |
||||||
|
href="/ai" |
||||||
|
className="flex w-full items-center gap-1.5 rounded-md bg-gray-900 px-3 py-1.5 text-xs text-white sm:w-auto sm:text-sm" |
||||||
|
> |
||||||
|
<Wand2 className="h-4 w-4" /> |
||||||
|
Create one with AI |
||||||
|
</a> |
||||||
|
<a |
||||||
|
href="/roadmaps" |
||||||
|
className="flex w-full items-center gap-1.5 rounded-md bg-yellow-400 px-3 py-1.5 text-xs text-black hover:bg-yellow-500 sm:w-auto sm:text-sm" |
||||||
|
> |
||||||
|
<Map className="h-4 w-4" /> |
||||||
|
Visit Official Roadmaps |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -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 ( |
||||||
|
<div className="relative w-full max-w-[350px]"> |
||||||
|
<label |
||||||
|
className="absolute left-3 flex h-full items-center text-gray-500" |
||||||
|
htmlFor="search" |
||||||
|
> |
||||||
|
<Search className="h-4 w-4" /> |
||||||
|
</label> |
||||||
|
<input |
||||||
|
id="search" |
||||||
|
name="search" |
||||||
|
type="text" |
||||||
|
placeholder="Type 3 or more characters to search..." |
||||||
|
className="w-full rounded-md border border-gray-200 px-3 py-2 pl-9 text-sm transition-colors focus:border-black focus:outline-none" |
||||||
|
value={term} |
||||||
|
onChange={(e) => setTerm(e.target.value)} |
||||||
|
/> |
||||||
|
{isLoading && ( |
||||||
|
<span className="absolute right-3 top-0 flex h-full items-center text-gray-500"> |
||||||
|
<Spinner isDualRing={false} className={`h-3 w-3`} /> |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -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 ( |
||||||
|
<div |
||||||
|
className="min-auto relative flex flex-shrink-0 sm:min-w-[140px]" |
||||||
|
ref={dropdownRef} |
||||||
|
> |
||||||
|
<button |
||||||
|
className="py-15 flex w-full items-center justify-between gap-2 rounded-md border px-2 text-sm" |
||||||
|
onClick={() => setIsOpen(!isOpen)} |
||||||
|
> |
||||||
|
<span>{selectedValue?.label}</span> |
||||||
|
|
||||||
|
<span> |
||||||
|
<ChevronDown className="ml-4 h-3.5 w-3.5" /> |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
|
||||||
|
{isOpen && ( |
||||||
|
<div className="absolute right-0 top-10 z-10 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"> |
||||||
|
{sortingLabels.map((item) => ( |
||||||
|
<button |
||||||
|
key={item.value} |
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100" |
||||||
|
onClick={() => { |
||||||
|
onSortChange(item.value); |
||||||
|
setIsOpen(false); |
||||||
|
}} |
||||||
|
> |
||||||
|
<span>{item.label}</span> |
||||||
|
{item.value === sortBy && <Check className="ml-auto h-4 w-4" />} |
||||||
|
</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,12 @@ |
|||||||
|
export function LoadingRoadmaps() { |
||||||
|
return ( |
||||||
|
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> |
||||||
|
{new Array(21).fill(0).map((_, index) => ( |
||||||
|
<li |
||||||
|
key={index} |
||||||
|
className="h-[95px] animate-pulse rounded-md border bg-gray-100" |
||||||
|
/> |
||||||
|
))} |
||||||
|
</ul> |
||||||
|
); |
||||||
|
} |
@ -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 ( |
||||||
|
<div |
||||||
|
className={cn('flex items-center', { |
||||||
|
'justify-between': variant === 'default', |
||||||
|
'justify-start': variant === 'minimal', |
||||||
|
})} |
||||||
|
> |
||||||
|
<div className="flex items-center gap-1 text-xs font-medium"> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
onPageChange(currPage - 1); |
||||||
|
}} |
||||||
|
disabled={currPage === 1 || isDisabled} |
||||||
|
className="rounded-md border px-2 py-1 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40" |
||||||
|
> |
||||||
|
← |
||||||
|
</button> |
||||||
|
{variant === 'default' && ( |
||||||
|
<> |
||||||
|
{pages.map((page, counter) => { |
||||||
|
if (page === 'more') { |
||||||
|
return ( |
||||||
|
<span |
||||||
|
key={`page-${page}-${counter}`} |
||||||
|
className="hidden sm:block" |
||||||
|
> |
||||||
|
<MoreHorizontal className="text-gray-400" size={14} /> |
||||||
|
</span> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<button |
||||||
|
key={`page-${page}`} |
||||||
|
disabled={isDisabled} |
||||||
|
onClick={() => { |
||||||
|
onPageChange(page as number); |
||||||
|
}} |
||||||
|
className={cn( |
||||||
|
'hidden rounded-md border px-2 py-1 hover:bg-gray-100 sm:block', |
||||||
|
{ |
||||||
|
'border-black text-black': currPage === page, |
||||||
|
}, |
||||||
|
)} |
||||||
|
> |
||||||
|
{page} |
||||||
|
</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</> |
||||||
|
)} |
||||||
|
<button |
||||||
|
disabled={currPage === totalPages || isDisabled} |
||||||
|
className="rounded-md border px-2 py-1 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40" |
||||||
|
onClick={() => { |
||||||
|
onPageChange(currPage + 1); |
||||||
|
}} |
||||||
|
> |
||||||
|
→ |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<span className="ml-2 hidden text-sm font-normal text-gray-500 sm:block"> |
||||||
|
Showing {formatCommaNumber((currPage - 1) * perPage)} to{' '} |
||||||
|
{formatCommaNumber((currPage - 1) * perPage + perPage)} of{' '} |
||||||
|
{formatCommaNumber(totalCount)} entries |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,36 @@ |
|||||||
|
import { useMemo } from 'react'; |
||||||
|
|
||||||
|
export function usePagination( |
||||||
|
currentPage: number, |
||||||
|
totalPages: number, |
||||||
|
maxPagesToShow: number, |
||||||
|
) { |
||||||
|
return useMemo(() => { |
||||||
|
const pages: Array<number | string> = []; |
||||||
|
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]); |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
export const formatter = Intl.NumberFormat('en-US', { |
||||||
|
useGrouping: true, |
||||||
|
}); |
||||||
|
|
||||||
|
export function formatCommaNumber(number: number): string { |
||||||
|
return formatter.format(number); |
||||||
|
} |
Loading…
Reference in new issue