diff --git a/src/client/components/infinite-scroll-sentinel.tsx b/src/client/components/infinite-scroll-sentinel.tsx new file mode 100644 index 0000000..8c17b5e --- /dev/null +++ b/src/client/components/infinite-scroll-sentinel.tsx @@ -0,0 +1,47 @@ +import { Loader2 } from "lucide-react"; +import { useEffect, useRef } from "react"; + +interface InfiniteScrollSentinelProps { + onVisible: () => void; + loading: boolean; +} + +export function InfiniteScrollSentinel({ + onVisible, + loading, +}: InfiniteScrollSentinelProps) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !loading) { + onVisible(); + } + }, + { rootMargin: "200px" }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [onVisible, loading]); + + return ( +
+ {loading ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/client/hooks/use-search.ts b/src/client/hooks/use-search.ts index e1d5922..e3463d5 100644 --- a/src/client/hooks/use-search.ts +++ b/src/client/hooks/use-search.ts @@ -1,4 +1,7 @@ -import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; +import { + infiniteQueryOptions, + useSuspenseInfiniteQuery, +} from "@tanstack/react-query"; import type { SearchCategory as SearchCategoryType, TimeRange as TimeRangeType, @@ -9,18 +12,16 @@ interface UseSearchOptions { query: string | undefined; category?: SearchCategoryType; timeRange?: TimeRangeType; - staleTime?: number; } -export const searchQueryOptions = ({ +export const infiniteSearchQueryOptions = ({ query, category, timeRange, - staleTime = Infinity, }: UseSearchOptions) => - queryOptions({ + infiniteQueryOptions({ queryKey: ["search", query, category, timeRange], - queryFn: async () => { + queryFn: async ({ pageParam }) => { if (!query) throw new Error("Query is required"); const result = await searchFn({ @@ -28,6 +29,7 @@ export const searchQueryOptions = ({ query, category, timeRange, + page: pageParam, }, }); @@ -37,13 +39,18 @@ export const searchQueryOptions = ({ return result.data; }, - staleTime, + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + if (lastPage.results.length === 0) return undefined; + return allPages.length + 1; + }, + staleTime: Infinity, gcTime: Infinity, refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: false, }); -export function useSearch(options: UseSearchOptions) { - return useSuspenseQuery(searchQueryOptions(options)); +export function useInfiniteSearch(options: UseSearchOptions) { + return useSuspenseInfiniteQuery(infiniteSearchQueryOptions(options)); } diff --git a/src/routes/_authed/search/index.tsx b/src/routes/_authed/search/index.tsx index c217165..bdc0b02 100644 --- a/src/routes/_authed/search/index.tsx +++ b/src/routes/_authed/search/index.tsx @@ -20,12 +20,16 @@ import { } from "react"; import z from "zod"; +import { InfiniteScrollSentinel } from "@/client/components/infinite-scroll-sentinel"; import { SearchBar } from "@/client/components/search-bar"; import { SearchLogo } from "@/client/components/search-logo"; import { ThemeToggle } from "@/client/components/theme-toggle"; import { Button } from "@/client/components/ui/button"; import { UserDropdown } from "@/client/components/user-dropdown"; -import { searchQueryOptions, useSearch } from "@/client/hooks/use-search"; +import { + infiniteSearchQueryOptions, + useInfiniteSearch, +} from "@/client/hooks/use-search"; import { SearchCategory, type SearchCategory as SearchCategoryType, @@ -71,8 +75,8 @@ export const Route = createFileRoute("/_authed/search/")({ // Normalize category to match component behavior and avoid double-fetch // Component defaults undefined category to SearchCategory.WEB void context.queryClient - .ensureQueryData( - searchQueryOptions({ + .prefetchInfiniteQuery( + infiniteSearchQueryOptions({ query: q, category: category ?? SearchCategory.WEB, timeRange, @@ -158,15 +162,29 @@ function SearchResultsList({ category: SearchCategoryType; timeRange?: TimeRangeType; }) { - const { data: searchResult } = useSearch({ - query, - category, - timeRange, - }); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteSearch({ query, category, timeRange }); if (!query) return null; - return ; + const mergedResults = { + results: data.pages.flatMap((p) => p.results), + count: data.pages.reduce((sum, p) => sum + p.count, 0), + duration: data.pages[0].duration, + cached: data.pages[0].cached, + }; + + return ( + <> + + {hasNextPage && ( + + )} + + ); } function SearchPage() { diff --git a/src/server/application/usecases/search.ts b/src/server/application/usecases/search.ts index 64353da..241f1b3 100644 --- a/src/server/application/usecases/search.ts +++ b/src/server/application/usecases/search.ts @@ -11,7 +11,14 @@ export const makeSearchUsecase = searchEngine: SearchEngine; cache: Cache; }): SearchUsecase => - async ({ query, category, timeRange, locale, safeSearch }: SearchInput) => { + async ({ + query, + category, + timeRange, + locale, + safeSearch, + page, + }: SearchInput) => { const startTime = performance.now(); const cacheKey = JSON.stringify({ @@ -20,6 +27,7 @@ export const makeSearchUsecase = timeRange, locale, safeSearch, + page, }); const cached = await cache.get(cacheKey); @@ -38,6 +46,7 @@ export const makeSearchUsecase = timeRange, locale, safeSearch, + page, }); const searchResult = { diff --git a/src/server/domain/value-objects/search.vo.ts b/src/server/domain/value-objects/search.vo.ts index 3bfc366..24b0840 100644 --- a/src/server/domain/value-objects/search.vo.ts +++ b/src/server/domain/value-objects/search.vo.ts @@ -83,6 +83,7 @@ export interface SearchInput { timeRange?: TimeRange; locale?: string; safeSearch?: SafeSearch; + page?: number; } export type BaseSearchResult = { diff --git a/src/server/infrastructure/functions/search.ts b/src/server/infrastructure/functions/search.ts index 25253e2..a1d7c24 100644 --- a/src/server/infrastructure/functions/search.ts +++ b/src/server/infrastructure/functions/search.ts @@ -25,6 +25,7 @@ const searchInputSchema = z.object({ .enum(Object.values(TimeRange) as [string, ...string[]]) .optional() .transform((val) => val as TimeRangeType | undefined), + page: z.coerce.number().int().min(1).optional(), }); const logger = withLogContext({ @@ -67,6 +68,7 @@ export const searchFn = createServerFn({ method: "GET" }) query: data.query, category: data.category, timeRange: data.timeRange, + page: data.page, }); requestContext.logger.info( { diff --git a/src/server/infrastructure/http/searxng/search-engine.ts b/src/server/infrastructure/http/searxng/search-engine.ts index 6a87ed7..0b1623d 100644 --- a/src/server/infrastructure/http/searxng/search-engine.ts +++ b/src/server/infrastructure/http/searxng/search-engine.ts @@ -116,7 +116,7 @@ export const makeSearXngSearchEngine = ({ }); return { - search: async ({ query, category, safeSearch, timeRange }) => { + search: async ({ query, category, safeSearch, timeRange, page }) => { const startedAt = performance.now(); const params = new URLSearchParams({ q: query, @@ -138,6 +138,10 @@ export const makeSearXngSearchEngine = ({ params.set("safesearch", safesearchParam); } + if (page && page > 1) { + params.set("pageno", String(page)); + } + const endpoint = "/search"; const response = await fetch( `${config.searxng.url}${endpoint}?${params.toString()}`, @@ -207,6 +211,7 @@ export const makeSearXngSearchEngine = ({ endpoint, status: response.status, resultCount: result.count, + page: page ?? 1, durationMs: Math.round(performance.now() - startedAt), }, "SearXNG search request completed",