feat/discover
Kamran Ahmed 5 months ago
parent e2fb99853c
commit 26bfc9c7e6
  1. 77
      src/components/DiscoverRoadmaps/DiscoverRoadmapSorting.tsx
  2. 194
      src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx
  3. 41
      src/components/DiscoverRoadmaps/SearchRoadmap.tsx
  4. 2
      src/components/ExploreAIRoadmap/LoadingRoadmaps.tsx
  5. 10
      src/components/Rating/Rating.tsx
  6. 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,63 +4,208 @@ 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';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
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 [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
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"> <>
{isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => {
setIsCreatingRoadmap(false);
}}
/>
)}
<div className="border-b bg-white py-7">
<div className="container text-left">
<div className="flex flex-col items-start bg-white">
<h1 className="mb-1 text-2xl font-bold sm:text-3xl">
Community Roadmaps
</h1>
<p className="text-base text-gray-500">
Browse the roadmaps created by the community or{' '}
<button
onClick={() => {
setIsCreatingRoadmap(true);
}}
className="rounded text-blue-600 underline"
>
create your own roadmap
</button>
</p>
</div>
</div>
</div>
<div className="py-3 bg-gray-50">
<section className="container mx-auto py-3">
<div className="mb-3.5 flex items-stretch justify-between gap-2.5">
<SearchRoadmap <SearchRoadmap
total={roadmapsResponse?.totalCount || 0} total={roadmapsResponse?.totalCount || 0}
value={titleQuery} value={pageState.searchTerm}
isLoading={isLoading}
onValueChange={(value) => {
}}
/> />
{roadmaps.length === 0 && <EmptyDiscoverRoadmaps />} <DiscoverRoadmapSorting
{roadmaps.length > 0 && ( sortBy={pageState.sortBy}
onSortChange={(sortBy) => {
setPageState({
...pageState,
sortBy,
});
}}
/>
</div>
{loadingIndicator}
{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-3 sm:grid-cols-2 lg:grid-cols-3">
{roadmaps.map((roadmap) => { {roadmaps.map((roadmap) => {
const roadmapLink = `/r/${roadmap.slug}`; const roadmapLink = `/r/${roadmap.slug}`;
const totalRatings = Object.keys(
roadmap.ratings?.breakdown || [],
).reduce(
(acc: number, key: string) =>
acc + roadmap.ratings.breakdown[key as any],
0,
);
return ( return (
<li key={roadmap._id} className="h-full"> <li key={roadmap._id} className="h-full min-h-[175px]">
<a <a
key={roadmap._id} key={roadmap._id}
href={roadmapLink} href={roadmapLink}
className="flex h-full flex-col rounded-md border transition-colors hover:bg-gray-100" className="flex h-full flex-col rounded-lg border bg-white p-3.5 transition-colors hover:border-gray-300 hover:bg-gray-50"
target={'_blank'} target={'_blank'}
> >
<div className="grow"> <div className="grow">
<h2 className="mt-2.5 px-2.5 text-base font-medium leading-tight"> <h2 className="text-balance text-base font-bold leading-tight">
{roadmap.title} {roadmap.title}
</h2> </h2>
<p className="my-2.5 px-2.5 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500">
{roadmap.description} {roadmap.description}
</p> </p>
</div> </div>
<div className="flex items-center justify-between gap-2 px-2.5 py-2"> <div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-xs text-gray-400"> <span className="flex items-center gap-1 text-xs text-gray-400">
<Shapes size={15} className="inline-block"/> <Shapes size={15} className="inline-block"/>
{Intl.NumberFormat('en-US', { {Intl.NumberFormat('en-US', {
notation: 'compact', notation: 'compact',
}).format(roadmap.topicCount)}{' '} }).format(roadmap.topicCount)}{' '}
topics
</span> </span>
<Rating <Rating
rating={roadmap?.ratings?.average || 0} rating={roadmap?.ratings?.average || 0}
readOnly={true} readOnly={true}
starSize={16} starSize={16}
total={totalRatings}
/> />
</div> </div>
</a> </a>
@ -75,17 +220,16 @@ 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()}`;
}} }}
/> />
</> </>
)} )}
</section> </section>
</div>
</>
); );
} }

@ -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">

@ -4,7 +4,7 @@ export function LoadingRoadmaps() {
{new Array(21).fill(0).map((_, index) => ( {new Array(21).fill(0).map((_, index) => (
<li <li
key={index} key={index}
className="h-[95px] animate-pulse rounded-md border bg-gray-100" className="h-[175px] animate-pulse rounded-md border bg-gray-100"
/> />
))} ))}
</ul> </ul>

@ -7,6 +7,7 @@ type RatingProps = {
starSize?: number; starSize?: number;
readOnly?: boolean; readOnly?: boolean;
className?: string; className?: string;
total?: number;
}; };
export function Rating(props: RatingProps) { export function Rating(props: RatingProps) {
@ -22,6 +23,10 @@ export function Rating(props: RatingProps) {
const starCount = Math.floor(stars); const starCount = Math.floor(stars);
const decimalWidthPercentage = Math.min((stars - starCount) * 100, 100); const decimalWidthPercentage = Math.min((stars - starCount) * 100, 100);
if (readOnly && starCount === 0) {
return <span className="text-xs text-gray-400">No ratings yet</span>;
}
return ( return (
<div className={cn('flex', className)}> <div className={cn('flex', className)}>
{[1, 2, 3, 4, 5].map((counter) => { {[1, 2, 3, 4, 5].map((counter) => {
@ -43,6 +48,11 @@ export function Rating(props: RatingProps) {
/> />
); );
})} })}
{props.total && (
<span className="ml-1.5 text-xs text-gray-400">
({Intl.NumberFormat('en-US').format(props.total)})
</span>
)}
</div> </div>
); );
} }

@ -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