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>
This commit is contained in:
Kamran AhmedandArik Chakma authored and GitHub committed 2024-03-20 15:38:08 +00:00
1 parent 6e6489bc4c
commit 812a39154c
11 files changed
+523 -113

No files matched your search

@@ -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>
);
}
@@ -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<AIRoadmapDocument[]>([]);
const [currPage, setCurrPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const loadAIRoadmaps = useCallback(
async (currPage: number) => {
const { response, error } = await httpGet<ExploreRoadmapsResponse>(
`${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<PageState>({
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<ExploreRoadmapsResponse | null>(null);
const loadAIRoadmaps = async (
currPage: number = 1,
searchTerm: string = '',
sortBy: SortByValues = 'createdAt',
) => {
const { response, error } = await httpGet<ExploreRoadmapsResponse>(
`${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 && <LoadingRoadmaps />;
const emptyRoadmaps = !isLoading && roadmaps.length === 0 && (
<EmptyRoadmaps />
);
const roadmapsList = !isLoading && roadmaps.length > 0 && (
<>
<AIRoadmapsList response={roadmapsResponse} />
<Pagination
currPage={roadmapsResponse?.currPage || 1}
totalPages={roadmapsResponse?.totalPages || 1}
perPage={roadmapsResponse?.perPage || 0}
isDisabled={isLoading}
totalCount={roadmapsResponse?.totalCount || 0}
onPageChange={(page) => {
setPageState({
...pageState,
currentPage: page,
});
}}
/>
</>
);
return (
<section className="container mx-auto py-3 sm:py-6">
<div className="mb-6">
<AIRoadmapAlert isListing />
<AIRoadmapAlert isListing />
<div className="my-3.5 flex items-stretch justify-between gap-2.5">
<ExploreAISearch
isLoading={isLoading}
value={pageState.searchTerm}
onSubmit={(term) => {
setPageState({
...pageState,
searchTerm: term,
currentPage: 1,
});
}}
/>
<ExploreAISorting
sortBy={pageState.sortBy}
onSortChange={(sortBy) => {
setPageState({
...pageState,
sortBy,
currentPage: 1,
});
}}
/>
</div>
{isLoading ? (
<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-[75px] animate-pulse rounded-md border bg-gray-100"
></li>
))}
</ul>
) : (
<div>
{roadmaps?.length === 0 ? (
<div className="text-center text-gray-800">No roadmaps found</div>
) : (
<>
<ul className="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 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>
{hasMorePages && (
<div className="my-5 flex items-center justify-center">
<button
onClick={() => {
setIsLoadingMore(true);
loadAIRoadmaps(currPage + 1).finally(() => {
setIsLoadingMore(false);
});
}}
className="inline-flex items-center gap-1.5 rounded-full bg-black px-3 py-1.5 text-sm font-medium text-white shadow-xl transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoadingMore}
>
{isLoadingMore ? (
<Loader2 className="h-4 w-4 animate-spin stroke-[2.5]" />
) : (
<RefreshCcw className="h-4 w-4 stroke-[2.5]" />
)}
Load More
</button>
</div>
)}
</>
)}
</div>
)}
{loadingIndicator}
{emptyRoadmaps}
{roadmapsList}
</section>
);
}
@@ -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>
);
}