diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index d3bd102..e47c6ae 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -43,12 +43,16 @@ export async function GET(request: Request) { ); } + const pageParam = searchParams.get("page"); + const page = pageParam ? Math.max(1, Number.parseInt(pageParam, 10) || 1) : 1; + try { const payload = await searchGitHubIssues({ tech, label: searchParams.get("label"), sort: searchParams.get("sort"), linkedPr: searchParams.get("linkedPr"), + page, }); return NextResponse.json(payload); diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index 66c0326..b6794e0 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -30,7 +30,7 @@ import { TECH_EXAMPLES, } from "@/features/issues/data/search-options"; import { compactNumber } from "@/features/issues/lib/format"; -import type { SearchResponse } from "@/features/issues/types/search"; +import type { SearchResponse, Issue } from "@/features/issues/types/search"; export function IssueFinder() { const [tech, setTech] = useState("Java"); @@ -38,7 +38,10 @@ export function IssueFinder() { const [sort, setSort] = useState("updated"); const [linkedPr, setLinkedPr] = useState("any"); const [data, setData] = useState(null); + const [issues, setIssues] = useState([]); + const [page, setPage] = useState(1); const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); const [cooldown, setCooldown] = useState(false); @@ -56,6 +59,11 @@ export function IssueFinder() { [sort], ); + const hasMore = useMemo(() => { + if (!data) return false; + return issues.length < data.totalCount && data.issues.length === 24; + }, [data, issues]); + async function searchIssues(event?: FormEvent) { event?.preventDefault(); @@ -67,6 +75,8 @@ export function IssueFinder() { setIsLoading(true); setCooldown(true); setError(null); + setIssues([]); + setPage(1); const params = new URLSearchParams({ tech: tech.trim(), @@ -84,6 +94,7 @@ export function IssueFinder() { } setData(payload); + setIssues(payload.issues); } catch (searchError) { setError( searchError instanceof Error @@ -98,6 +109,43 @@ export function IssueFinder() { } } + async function loadMoreIssues() { + if (isLoadingMore || !tech.trim() || !data) return; + + setIsLoadingMore(true); + setError(null); + + const nextPage = page + 1; + const params = new URLSearchParams({ + tech: tech.trim(), + label, + sort, + linkedPr, + page: String(nextPage), + }); + + try { + const response = await fetch(`/api/search?${params.toString()}`); + const payload = (await response.json()) as SearchResponse; + + if (!response.ok) { + throw new Error(payload.error ?? "Failed to load more issues."); + } + + setIssues((prev) => [...prev, ...payload.issues]); + setPage(nextPage); + setData(payload); + } catch (searchError) { + setError( + searchError instanceof Error + ? searchError.message + : "Failed to load more issues.", + ); + } finally { + setIsLoadingMore(false); + } + } + return (
@@ -288,7 +336,7 @@ export function IssueFinder() { {isLoading ? : null} - {!isLoading && data?.issues.length === 0 ? ( + {!isLoading && data && issues.length === 0 ? ( No matching issues @@ -299,7 +347,22 @@ export function IssueFinder() { ) : null} - {!isLoading && data?.issues.map((issue) => )} + {!isLoading && issues.map((issue) => )} + + {!isLoading && hasMore && ( +
+ +
+ )}
diff --git a/src/features/issues/server/github-search.ts b/src/features/issues/server/github-search.ts index f547b45..4c204db 100644 --- a/src/features/issues/server/github-search.ts +++ b/src/features/issues/server/github-search.ts @@ -143,11 +143,13 @@ export async function searchGitHubIssues({ label: rawLabel, sort: rawSort, linkedPr: rawLinkedPr, + page = 1, }: { tech: string; label: string | null; sort: string | null; linkedPr: string | null; + page?: number; }): Promise { const label = GITHUB_LABELS[normalize(rawLabel)] ?? "help wanted"; const sort = GITHUB_SORTS.has(rawSort ?? "") ? rawSort! : "updated"; @@ -173,6 +175,7 @@ export async function searchGitHubIssues({ url.searchParams.set("sort", sort); url.searchParams.set("order", "desc"); url.searchParams.set("per_page", "24"); + url.searchParams.set("page", String(page)); const search = await githubFetch(url.toString(), token, 180); const repoNames = token @@ -273,5 +276,6 @@ export async function searchGitHubIssues({ rateLimitRemaining: search.rateLimitRemaining, tokenConfigured: Boolean(token), issues, + page, }; } diff --git a/src/features/issues/types/search.ts b/src/features/issues/types/search.ts index 0cb8fdc..4b4c4e1 100644 --- a/src/features/issues/types/search.ts +++ b/src/features/issues/types/search.ts @@ -27,6 +27,7 @@ export type SearchResponse = { rateLimitRemaining: string | null; tokenConfigured: boolean; issues: Issue[]; + page: number; error?: string; }; diff --git a/tests/app/api/search/route.test.ts b/tests/app/api/search/route.test.ts index d61740c..375f6c7 100644 --- a/tests/app/api/search/route.test.ts +++ b/tests/app/api/search/route.test.ts @@ -19,6 +19,7 @@ describe("GET /api/search", () => { rateLimitRemaining: "4999", tokenConfigured: false, issues: [], + page: 1, }); }); @@ -48,6 +49,7 @@ describe("GET /api/search", () => { label: "good-first-issue", sort: "created", linkedPr: "yes", + page: 1, }); }); diff --git a/tests/features/issues/server/github-search.test.ts b/tests/features/issues/server/github-search.test.ts index 328d3f7..4c04af3 100644 --- a/tests/features/issues/server/github-search.test.ts +++ b/tests/features/issues/server/github-search.test.ts @@ -100,6 +100,8 @@ describe("searchGitHubIssues", () => { expect(searchUrl.searchParams.get("q")).toBe( 'is:issue is:open archived:false language:TypeScript label:"good first issue" linked:pr', ); + expect(searchUrl.searchParams.get("page")).toBe("1"); + expect(result.page).toBe(1); expect(result.issues[0]).toMatchObject({ repo: "acme/widgets", linkedPrCount: 1,