refactor: roadmap discover page

feat/discover
Arik Chakma 5 months ago
parent e2fb99853c
commit 05b549b096
  1. 77
      src/components/DiscoverRoadmaps/DiscoverRoadmapSorting.tsx
  2. 139
      src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx
  3. 41
      src/components/DiscoverRoadmaps/SearchRoadmap.tsx
  4. 21
      src/pages/discover.astro

@ -0,0 +1,77 @@
import { ArrowDownWideNarrow, Check, ChevronDown } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import type { SortByValues } from './DiscoverRoadmaps';
const sortingLabels: { label: string; value: SortByValues }[] = [
{
label: 'Newest',
value: 'createdAt',
},
{
label: 'Oldest',
value: '-createdAt',
},
{
label: 'Highest Rated',
value: 'rating',
},
{
label: 'Lowest Rated',
value: '-rating',
},
];
type DiscoverRoadmapSortingProps = {
sortBy: SortByValues;
onSortChange: (sortBy: SortByValues) => void;
};
export function DiscoverRoadmapSorting(props: DiscoverRoadmapSortingProps) {
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>
);
}

@ -4,29 +4,133 @@ import { Pagination } from '../Pagination/Pagination';
import { SearchRoadmap } from './SearchRoadmap'; import { SearchRoadmap } from './SearchRoadmap';
import { EmptyDiscoverRoadmaps } from './EmptyDiscoverRoadmaps'; import { EmptyDiscoverRoadmaps } from './EmptyDiscoverRoadmaps';
import { Rating } from '../Rating/Rating'; import { Rating } from '../Rating/Rating';
import { useEffect, useState } from 'react';
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
import { LoadingRoadmaps } from '../ExploreAIRoadmap/LoadingRoadmaps';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { DiscoverRoadmapSorting } from './DiscoverRoadmapSorting';
type DiscoverRoadmapsProps = { type DiscoverRoadmapsProps = {};
searchParams: string;
roadmapsResponse: ListShowcaseRoadmapResponse; export type SortByValues = 'rating' | '-rating' | 'createdAt' | '-createdAt';
type QueryParams = {
q?: string;
s?: SortByValues;
p?: string;
};
type PageState = {
searchTerm: string;
sortBy: SortByValues;
currentPage: number;
}; };
export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) { export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) {
const { roadmapsResponse, searchParams: defaultSearchparams } = props; const toast = useToast();
const [pageState, setPageState] = useState<PageState>({
searchTerm: '',
sortBy: 'createdAt',
currentPage: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [roadmapsResponse, setRoadmapsResponse] =
useState<ListShowcaseRoadmapResponse | null>(null);
useEffect(() => {
const queryParams = getUrlParams() as QueryParams;
setPageState({
searchTerm: queryParams.q || '',
sortBy: queryParams.s || 'createdAt',
currentPage: +(queryParams.p || '1'),
});
}, []);
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 loadAIRoadmaps = async (
currPage: number = 1,
searchTerm: string = '',
sortBy: SortByValues = 'createdAt',
) => {
const { response, error } = await httpGet<ListShowcaseRoadmapResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-showcase-roadmap`,
{
currPage,
...(searchTerm && { term: searchTerm }),
...(sortBy && { sortBy }),
},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
setRoadmapsResponse(response);
};
const roadmaps = roadmapsResponse?.data || []; const roadmaps = roadmapsResponse?.data || [];
const searchParams = new URLSearchParams(defaultSearchparams); const loadingIndicator = isLoading && <LoadingRoadmaps />;
const titleQuery = searchParams.get('q') || '';
return ( return (
<section className="container mx-auto py-3 sm:py-6"> <section className="container mx-auto py-3 sm:py-6">
<SearchRoadmap <div className="mb-3.5 flex items-stretch justify-between gap-2.5">
total={roadmapsResponse?.totalCount || 0} <SearchRoadmap
value={titleQuery} total={roadmapsResponse?.totalCount || 0}
/> value={pageState.searchTerm}
isLoading={isLoading}
onValueChange={(value) => {}}
/>
<DiscoverRoadmapSorting
sortBy={pageState.sortBy}
onSortChange={(sortBy) => {
setPageState({
...pageState,
sortBy,
});
}}
/>
</div>
{roadmaps.length === 0 && <EmptyDiscoverRoadmaps />} {loadingIndicator}
{roadmaps.length > 0 && ( {roadmaps.length === 0 && !isLoading && <EmptyDiscoverRoadmaps />}
{roadmaps.length > 0 && !isLoading && (
<> <>
<ul className="mb-4 grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2 lg:grid-cols-3"> <ul className="mb-4 grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2 lg:grid-cols-3">
{roadmaps.map((roadmap) => { {roadmaps.map((roadmap) => {
@ -75,13 +179,10 @@ export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) {
perPage={roadmapsResponse?.perPage || 0} perPage={roadmapsResponse?.perPage || 0}
totalCount={roadmapsResponse?.totalCount || 0} totalCount={roadmapsResponse?.totalCount || 0}
onPageChange={(page) => { onPageChange={(page) => {
const newSearchParams = new URLSearchParams(); setPageState({
if (titleQuery) { ...pageState,
newSearchParams.set('q', titleQuery); currentPage: page,
} });
newSearchParams.set('currPage', page.toString());
window.location.href = `/discover?${newSearchParams.toString()}`;
}} }}
/> />
</> </>

@ -1,18 +1,45 @@
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useDebounceValue } from '../../hooks/use-debounce';
import { Spinner } from '../ReactIcons/Spinner';
type SearchRoadmapProps = { type SearchRoadmapProps = {
value: string; value: string;
total: number; total: number;
isLoading: boolean;
onValueChange: (value: string) => void;
}; };
export function SearchRoadmap(props: SearchRoadmapProps) { export function SearchRoadmap(props: SearchRoadmapProps) {
const { total, value: defaultValue } = props; const { total, value: defaultValue, onValueChange, isLoading } = 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;
}
onValueChange(debouncedTerm);
}, [debouncedTerm]);
return ( return (
<div className="relative mb-3 flex w-full items-center gap-3"> <div className="relative flex w-full items-center gap-3">
<form <form
className="relative flex w-full max-w-[310px] items-center" className="relative flex w-full max-w-[310px] items-center"
action="/discover" onSubmit={(e) => {
e.preventDefault();
onValueChange(term);
}}
> >
<label <label
className="absolute left-3 flex h-full items-center text-gray-500" className="absolute left-3 flex h-full items-center text-gray-500"
@ -27,8 +54,14 @@ export function SearchRoadmap(props: SearchRoadmapProps) {
minLength={3} minLength={3}
placeholder="Type 3 or more characters to search..." 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" 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"
defaultValue={defaultValue} 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>
)}
</form> </form>
{total > 0 && ( {total > 0 && (
<p className="hidden flex-shrink-0 text-sm text-gray-500 sm:block"> <p className="hidden flex-shrink-0 text-sm text-gray-500 sm:block">

@ -1,27 +1,8 @@
--- ---
import { roadmapApi } from '../api/roadmap';
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import { DiscoverRoadmaps } from '../components/DiscoverRoadmaps/DiscoverRoadmaps'; import { DiscoverRoadmaps } from '../components/DiscoverRoadmaps/DiscoverRoadmaps';
import { DiscoverError } from '../components/DiscoverRoadmaps/DiscoverError';
export const prerender = false;
const roadmapApiClient = roadmapApi(Astro);
const { error, response: roadmaps } =
await roadmapApiClient.listShowcaseRoadmap();
const searchParams = Astro.url.searchParams.toString();
--- ---
<BaseLayout title='Discover Custom Roadmaps'> <BaseLayout title='Discover Custom Roadmaps'>
{error && <DiscoverError message={error.message} />} <DiscoverRoadmaps client:load />
{
roadmaps && (
<DiscoverRoadmaps
roadmapsResponse={roadmaps}
searchParams={searchParams}
client:load
/>
)
}
</BaseLayout> </BaseLayout>

Loading…
Cancel
Save