Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/client/components/infinite-scroll-sentinel.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div ref={ref} className="flex justify-center py-8">
{loading ? (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground/50" />
) : (
<button
type="button"
onClick={onVisible}
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors duration-200 px-4 py-2 rounded-lg hover:bg-muted/40"
>
Load more
</button>
)}
</div>
);
}
25 changes: 16 additions & 9 deletions src/client/hooks/use-search.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,25 +12,24 @@ 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({
data: {
query,
category,
timeRange,
page: pageParam,
},
});

Expand All @@ -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));
}
36 changes: 27 additions & 9 deletions src/routes/_authed/search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 <SearchResults query={query} results={searchResult} />;
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 (
<>
<SearchResults query={query} results={mergedResults} />
{hasNextPage && (
<InfiniteScrollSentinel
onVisible={fetchNextPage}
loading={isFetchingNextPage}
/>
)}
</>
);
}

function SearchPage() {
Expand Down
11 changes: 10 additions & 1 deletion src/server/application/usecases/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ export const makeSearchUsecase =
searchEngine: SearchEngine;
cache: Cache<SearchResult>;
}): SearchUsecase =>
async ({ query, category, timeRange, locale, safeSearch }: SearchInput) => {
async ({
query,
category,
timeRange,
locale,
safeSearch,
page,
}: SearchInput) => {
const startTime = performance.now();

const cacheKey = JSON.stringify({
Expand All @@ -20,6 +27,7 @@ export const makeSearchUsecase =
timeRange,
locale,
safeSearch,
page,
});

const cached = await cache.get(cacheKey);
Expand All @@ -38,6 +46,7 @@ export const makeSearchUsecase =
timeRange,
locale,
safeSearch,
page,
});

const searchResult = {
Expand Down
1 change: 1 addition & 0 deletions src/server/domain/value-objects/search.vo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export interface SearchInput {
timeRange?: TimeRange;
locale?: string;
safeSearch?: SafeSearch;
page?: number;
}

export type BaseSearchResult = {
Expand Down
2 changes: 2 additions & 0 deletions src/server/infrastructure/functions/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(
{
Expand Down
7 changes: 6 additions & 1 deletion src/server/infrastructure/http/searxng/search-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()}`,
Expand Down Expand Up @@ -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",
Expand Down