mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-11 19:53:59 +02:00
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:
57
src/components/ExploreAIRoadmap/AIRoadmapsList.tsx
Normal file
57
src/components/ExploreAIRoadmap/AIRoadmapsList.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
31
src/components/ExploreAIRoadmap/EmptyRoadmaps.tsx
Normal file
31
src/components/ExploreAIRoadmap/EmptyRoadmaps.tsx
Normal file
@@ -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 { useToast } from '../../hooks/use-toast';
|
||||||
import { httpGet } from '../../lib/http';
|
import { httpGet } from '../../lib/http';
|
||||||
import { getRelativeTimeString } from '../../lib/date';
|
|
||||||
import { Eye, Loader2, RefreshCcw } from 'lucide-react';
|
|
||||||
import { AIRoadmapAlert } from '../GenerateRoadmap/AIRoadmapAlert.tsx';
|
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 {
|
export interface AIRoadmapDocument {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
@@ -23,21 +33,84 @@ type ExploreRoadmapsResponse = {
|
|||||||
perPage: number;
|
perPage: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type QueryParams = {
|
||||||
|
q?: string;
|
||||||
|
s?: SortByValues;
|
||||||
|
p?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageState = {
|
||||||
|
searchTerm: string;
|
||||||
|
sortBy: SortByValues;
|
||||||
|
currentPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function ExploreAIRoadmap() {
|
export function ExploreAIRoadmap() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [pageState, setPageState] = useState<PageState>({
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
searchTerm: '',
|
||||||
const [roadmaps, setRoadmaps] = useState<AIRoadmapDocument[]>([]);
|
sortBy: 'createdAt',
|
||||||
const [currPage, setCurrPage] = useState(1);
|
currentPage: 0,
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
});
|
||||||
|
|
||||||
const loadAIRoadmaps = useCallback(
|
useEffect(() => {
|
||||||
async (currPage: number) => {
|
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 [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>(
|
const { response, error } = await httpGet<ExploreRoadmapsResponse>(
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-ai-roadmaps`,
|
`${import.meta.env.PUBLIC_API_URL}/v1-list-ai-roadmaps`,
|
||||||
{
|
{
|
||||||
currPage,
|
currPage,
|
||||||
|
...(searchTerm && { term: searchTerm }),
|
||||||
|
...(sortBy && { sortBy }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -46,104 +119,67 @@ export function ExploreAIRoadmap() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRoadmaps = [...roadmaps, ...response.data];
|
setRoadmapsResponse(response);
|
||||||
if (
|
};
|
||||||
JSON.stringify(roadmaps) === JSON.stringify(response.data) ||
|
|
||||||
JSON.stringify(roadmaps) === JSON.stringify(newRoadmaps)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRoadmaps(newRoadmaps);
|
const roadmaps = roadmapsResponse?.data || [];
|
||||||
setCurrPage(response.currPage);
|
|
||||||
setTotalPages(response.totalPages);
|
const loadingIndicator = isLoading && <LoadingRoadmaps />;
|
||||||
},
|
const emptyRoadmaps = !isLoading && roadmaps.length === 0 && (
|
||||||
[currPage, roadmaps],
|
<EmptyRoadmaps />
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const roadmapsList = !isLoading && roadmaps.length > 0 && (
|
||||||
loadAIRoadmaps(currPage).finally(() => {
|
<>
|
||||||
setIsLoading(false);
|
<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,
|
||||||
});
|
});
|
||||||
}, []);
|
}}
|
||||||
|
/>
|
||||||
const hasMorePages = currPage < totalPages;
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="container mx-auto py-3 sm:py-6">
|
<section className="container mx-auto py-3 sm:py-6">
|
||||||
<div className="mb-6">
|
|
||||||
<AIRoadmapAlert isListing />
|
<AIRoadmapAlert isListing />
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
<div className="my-3.5 flex items-stretch justify-between gap-2.5">
|
||||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
<ExploreAISearch
|
||||||
{new Array(21).fill(0).map((_, index) => (
|
isLoading={isLoading}
|
||||||
<li
|
value={pageState.searchTerm}
|
||||||
key={index}
|
onSubmit={(term) => {
|
||||||
className="h-[75px] animate-pulse rounded-md border bg-gray-100"
|
setPageState({
|
||||||
></li>
|
...pageState,
|
||||||
))}
|
searchTerm: term,
|
||||||
</ul>
|
currentPage: 1,
|
||||||
) : (
|
|
||||||
<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}
|
|
||||||
>
|
<ExploreAISorting
|
||||||
{isLoadingMore ? (
|
sortBy={pageState.sortBy}
|
||||||
<Loader2 className="h-4 w-4 animate-spin stroke-[2.5]" />
|
onSortChange={(sortBy) => {
|
||||||
) : (
|
setPageState({
|
||||||
<RefreshCcw className="h-4 w-4 stroke-[2.5]" />
|
...pageState,
|
||||||
)}
|
sortBy,
|
||||||
Load More
|
currentPage: 1,
|
||||||
</button>
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
{loadingIndicator}
|
||||||
)}
|
{emptyRoadmaps}
|
||||||
</div>
|
{roadmapsList}
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
58
src/components/ExploreAIRoadmap/ExploreAISearch.tsx
Normal file
58
src/components/ExploreAIRoadmap/ExploreAISearch.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
73
src/components/ExploreAIRoadmap/ExploreAISorting.tsx
Normal file
73
src/components/ExploreAIRoadmap/ExploreAISorting.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
12
src/components/ExploreAIRoadmap/LoadingRoadmaps.tsx
Normal file
12
src/components/ExploreAIRoadmap/LoadingRoadmaps.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
@@ -314,7 +314,7 @@ export function GenerateRoadmap() {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
setRoadmapTerm(title);
|
setRoadmapTerm(term);
|
||||||
setGeneratedRoadmapContent(data);
|
setGeneratedRoadmapContent(data);
|
||||||
visitAIRoadmap(roadmapId);
|
visitAIRoadmap(roadmapId);
|
||||||
};
|
};
|
||||||
|
101
src/components/Pagination/Pagination.tsx
Normal file
101
src/components/Pagination/Pagination.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
36
src/hooks/use-pagination.ts
Normal file
36
src/hooks/use-pagination.ts
Normal file
@@ -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]);
|
||||||
|
}
|
7
src/lib/number.ts
Normal file
7
src/lib/number.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const formatter = Intl.NumberFormat('en-US', {
|
||||||
|
useGrouping: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatCommaNumber(number: number): string {
|
||||||
|
return formatter.format(number);
|
||||||
|
}
|
@@ -6,5 +6,4 @@ import AccountLayout from '../../layouts/AccountLayout.astro';
|
|||||||
|
|
||||||
<AccountLayout title='Explore AI Generated Roadmaps'>
|
<AccountLayout title='Explore AI Generated Roadmaps'>
|
||||||
<ExploreAIRoadmap client:load />
|
<ExploreAIRoadmap client:load />
|
||||||
<LoginPopup />
|
|
||||||
</AccountLayout>
|
</AccountLayout>
|
||||||
|
Reference in New Issue
Block a user