From d8d6b5d2d717d57d004e27ba6745bc184056608d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 13:12:38 -0700 Subject: [PATCH] Add search UI for full-text entry search (#565) Adds a /search page that exposes the existing backend full-text search (entries.list query parameter) in the UI. The search input debounces user input and updates the URL's ?q= parameter, which drives the PostgreSQL full-text search with relevance ranking. Features: - Search page at /search with autofocusing search input - Search icon button in the header for quick access - "/" keyboard shortcut navigates to search (like Gmail/GitHub) - Debounced input (300ms) updates URL, triggering new search - Results ranked by relevance via PostgreSQL ts_rank - "Type a search query" prompt when no query entered - Clear button to reset search - Keyboard shortcuts disabled while search input is focused - Search documented in keyboard shortcuts modal (? key) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/(app)/AppLayoutContent.tsx | 10 ++ src/app/(app)/search/page.tsx | 16 ++ src/components/entries/EntryListPage.tsx | 12 +- .../entries/UnifiedEntriesContent.tsx | 61 +++++++- .../keyboard/KeyboardShortcutsModal.tsx | 1 + src/components/search/SearchInput.tsx | 105 +++++++++++++ src/components/ui/icon-button.tsx | 16 ++ src/lib/hooks/useEntriesListInput.ts | 14 +- src/lib/hooks/useKeyboardShortcuts.ts | 14 ++ src/lib/hooks/viewPreferences.ts | 3 +- src/lib/queries/entries-list-input.ts | 11 +- .../unit/frontend/entries-list-input.test.ts | 140 ++++++++++++++++++ 12 files changed, 390 insertions(+), 13 deletions(-) create mode 100644 src/app/(app)/search/page.tsx create mode 100644 src/components/search/SearchInput.tsx create mode 100644 tests/unit/frontend/entries-list-input.test.ts diff --git a/src/app/(app)/AppLayoutContent.tsx b/src/app/(app)/AppLayoutContent.tsx index 6cdc9f21..9583880e 100644 --- a/src/app/(app)/AppLayoutContent.tsx +++ b/src/app/(app)/AppLayoutContent.tsx @@ -15,6 +15,7 @@ import { CloseIcon, MenuIcon, PlusIcon, + SearchIcon, UserIcon, ChevronDownIcon, } from "@/components/ui/icon-button"; @@ -95,6 +96,15 @@ export function AppLayoutContent({ initialCursors }: AppLayoutContentProps) { } headerRight={
+ {/* Search button */} + + + + {/* Subscribe button */} ; +} + +export default function SearchPage({ searchParams }: SearchPageProps) { + return ; +} diff --git a/src/components/entries/EntryListPage.tsx b/src/components/entries/EntryListPage.tsx index 9433c84f..ed1622c3 100644 --- a/src/components/entries/EntryListPage.tsx +++ b/src/components/entries/EntryListPage.tsx @@ -66,6 +66,12 @@ export async function EntryListPage({ pathname, searchParams, children }: EntryL const filters = getFiltersFromPathname(pathname); const defaults = getDefaultViewPreferences(pathname); + // Read search query from URL params (used on /search page) + const q = typeof params.q === "string" ? params.q.trim() || undefined : undefined; + if (q) { + filters.query = q; + } + // Parse view preferences from URL, using route-specific defaults const urlParams = new URLSearchParams(); if (params.unreadOnly) urlParams.set("unreadOnly", String(params.unreadOnly)); @@ -78,7 +84,11 @@ export async function EntryListPage({ pathname, searchParams, children }: EntryL const input = buildEntriesListInput(filters, { unreadOnly, sortOrder }); // Prefetch the entry list and related data - void trpc.entries.list.prefetchInfinite(input); + // Skip prefetch on /search without a query — client shows a prompt instead + const shouldPrefetch = !(pathname === "/search" && !q); + if (shouldPrefetch) { + void trpc.entries.list.prefetchInfinite(input); + } if (entryId != null) { void trpc.entries.get.prefetch({ id: entryId }); } diff --git a/src/components/entries/UnifiedEntriesContent.tsx b/src/components/entries/UnifiedEntriesContent.tsx index a391e52b..afd4dbff 100644 --- a/src/components/entries/UnifiedEntriesContent.tsx +++ b/src/components/entries/UnifiedEntriesContent.tsx @@ -21,6 +21,7 @@ import { EntryPageLayout, TitleSkeleton, TitleText } from "./EntryPageLayout"; import { EntryContent } from "./EntryContent"; import { SuspendingEntryList } from "./SuspendingEntryList"; import { EntryListFallback } from "./EntryListFallback"; +import { EntryListEmpty } from "./EntryListStates"; import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { NotFoundCard } from "@/components/ui/not-found-card"; import { useEntryUrlState } from "@/lib/hooks/useEntryUrlState"; @@ -30,6 +31,10 @@ import { type ViewType } from "@/lib/hooks/viewPreferences"; import { trpc } from "@/lib/trpc/client"; import { findCachedSubscription } from "@/lib/cache/count-cache"; import { type EntryType } from "@/lib/hooks/useEntryMutations"; +import { SearchIcon } from "@/components/ui/icon-button"; +import { SearchInput } from "@/components/search/SearchInput"; + +const SEARCH_EMPTY_PROMPT = "Type a search query above to find entries."; /** * Route info derived from the current pathname. @@ -197,6 +202,21 @@ function useRouteInfo(): RouteInfo { }; } + // /search - Full-text search + // Note: empty messages are overridden by the emptyMessages memo in + // UnifiedEntriesContentInner which checks queryInput.query dynamically + if (pathname === "/search") { + return { + viewId: "search" as const, + filters: {}, + title: "Search", // Replaced by SearchInput in title slot + hideSortToggle: true, + emptyMessageUnread: "No matching entries found.", + emptyMessageAll: "No matching entries found.", + markAllReadDescription: "search results", + }; + } + // /recently-read - Recently read entries if (pathname === "/recently-read") { return { @@ -319,11 +339,15 @@ function UnifiedEntriesContentInner() { // Get query input based on current URL - shared with SuspendingEntryList const queryInput = useEntriesListInput(); + // Don't fetch when on search page without a query + const shouldFetchEntries = !(routeInfo.viewId === "search" && !queryInput.query); + // Non-suspending query for navigation - shares cache with SuspendingEntryList const entriesQuery = trpc.entries.list.useInfiniteQuery(queryInput, { getNextPageParam: (lastPage) => lastPage.nextCursor, staleTime: Infinity, refetchOnWindowFocus: false, + enabled: shouldFetchEntries, }); // Fetch subscription data for validation @@ -337,8 +361,18 @@ function UnifiedEntriesContentInner() { enabled: !!routeInfo.tagId, }); - // Update empty messages with actual tag name if available + // Update empty messages based on context const emptyMessages = useMemo(() => { + // Search page: customize message based on whether there's a query + if (routeInfo.viewId === "search") { + const hasQuery = !!queryInput.query; + return { + emptyMessageUnread: hasQuery ? "No matching entries found." : SEARCH_EMPTY_PROMPT, + emptyMessageAll: hasQuery ? "No matching entries found." : SEARCH_EMPTY_PROMPT, + markAllReadDescription: "search results", + }; + } + if (routeInfo.tagId && tagsQuery.data) { const tag = tagsQuery.data.items.find((t) => t.id === routeInfo.tagId); const tagName = tag?.name ?? "this tag"; @@ -353,7 +387,7 @@ function UnifiedEntriesContentInner() { emptyMessageAll: routeInfo.emptyMessageAll, markAllReadDescription: routeInfo.markAllReadDescription, }; - }, [routeInfo, tagsQuery.data]); + }, [routeInfo, tagsQuery.data, queryInput.query]); // Build mark all read options const markAllReadOptions = useMemo(() => { @@ -445,11 +479,15 @@ function UnifiedEntriesContentInner() { } // Title has its own Suspense boundary with smart fallback that uses cache - const titleSlot = ( - }> - - - ); + // Search page uses SearchInput instead of a text title + const titleSlot = + routeInfo.viewId === "search" ? ( + + ) : ( + }> + + + ); // Entry content - has its own internal Suspense boundary const entryContentSlot = openEntryId ? ( @@ -465,7 +503,14 @@ function UnifiedEntriesContentInner() { ) : null; // Entry list - has its own Suspense boundary - const entryListSlot = ( + // For search without a query, show a prompt instead of fetching all entries + const isSearchWithoutQuery = routeInfo.viewId === "search" && !queryInput.query; + const entryListSlot = isSearchWithoutQuery ? ( + } + /> + ) : ( (null); + const { setEnabled: setKeyboardShortcutsEnabled } = useKeyboardShortcutsContext(); + + // Track the last value we wrote to the URL to distinguish external changes + // (e.g., browser back/forward) from our own debounced updates + const lastWrittenRef = useRef(urlQuery); + + // Sync input value when URL changes externally (browser back/forward). + // We detect external changes by comparing the URL value with what we last wrote. + if (urlQuery !== lastWrittenRef.current) { + lastWrittenRef.current = urlQuery; + if (urlQuery !== inputValue) { + setInputValue(urlQuery); + } + } + + // Debounce URL updates + useEffect(() => { + const timer = setTimeout(() => { + if (inputValue !== urlQuery) { + const params = new URLSearchParams(searchParams?.toString() ?? ""); + if (inputValue) { + params.set("q", inputValue); + } else { + params.delete("q"); + } + const queryString = params.toString(); + const url = queryString ? `/search?${queryString}` : "/search"; + lastWrittenRef.current = inputValue; + clientReplace(url); + } + }, DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [inputValue, urlQuery, searchParams]); + + // Autofocus on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Disable keyboard shortcuts while input is focused + const handleFocus = useCallback(() => { + setKeyboardShortcutsEnabled(false); + }, [setKeyboardShortcutsEnabled]); + + const handleBlur = useCallback(() => { + setKeyboardShortcutsEnabled(true); + }, [setKeyboardShortcutsEnabled]); + + const handleClear = useCallback(() => { + setInputValue(""); + inputRef.current?.focus(); + }, []); + + return ( +
+ + setInputValue(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + placeholder="Search entries..." + aria-label="Search entries" + className="ui-text-sm w-full rounded-lg border border-zinc-200 bg-white py-2 pr-8 pl-9 text-zinc-900 placeholder-zinc-400 transition-colors outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder-zinc-500 dark:focus:border-zinc-500 dark:focus:ring-zinc-500" + /> + {inputValue && ( + + )} +
+ ); +} diff --git a/src/components/ui/icon-button.tsx b/src/components/ui/icon-button.tsx index dbf27d2a..86fa2f4f 100644 --- a/src/components/ui/icon-button.tsx +++ b/src/components/ui/icon-button.tsx @@ -1003,6 +1003,22 @@ export function StopIcon({ className = "h-4 w-4" }: IconProps) { ); } +/** + * Search/magnifying glass icon + */ +export function SearchIcon({ className = "h-4 w-4" }: IconProps) { + return ( + + + + ); +} + /** * Question circle icon (help) */ diff --git a/src/lib/hooks/useEntriesListInput.ts b/src/lib/hooks/useEntriesListInput.ts index 46d1aab1..561eb357 100644 --- a/src/lib/hooks/useEntriesListInput.ts +++ b/src/lib/hooks/useEntriesListInput.ts @@ -8,7 +8,7 @@ "use client"; import { useMemo } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; import { useUrlViewPreferences } from "./useUrlViewPreferences"; import { buildEntriesListInput, @@ -23,14 +23,24 @@ import { * - The suspending entry list component (for fetching) * - The parent component (for navigation/cache reading) * + * Reads the `q` search param for full-text search on the /search page. + * * @returns The query input object for entries.list */ export function useEntriesListInput(): EntriesListInput { const pathname = usePathname(); + const searchParams = useSearchParams(); const { showUnreadOnly, sortOrder } = useUrlViewPreferences(); return useMemo(() => { const filters = getFiltersFromPathname(pathname); + + // Read search query from URL params (used on /search page) + const q = searchParams?.get("q")?.trim() || undefined; + if (q) { + filters.query = q; + } + return buildEntriesListInput(filters, { unreadOnly: showUnreadOnly, sortOrder }); - }, [pathname, showUnreadOnly, sortOrder]); + }, [pathname, searchParams, showUnreadOnly, sortOrder]); } diff --git a/src/lib/hooks/useKeyboardShortcuts.ts b/src/lib/hooks/useKeyboardShortcuts.ts index 70cf7825..ebd59b59 100644 --- a/src/lib/hooks/useKeyboardShortcuts.ts +++ b/src/lib/hooks/useKeyboardShortcuts.ts @@ -481,6 +481,20 @@ export function useKeyboardShortcuts( [onRefresh, isEntryOpen, enabled] ); + // / - open search + useHotkeys( + "/", + (e) => { + e.preventDefault(); + clientPush("/search"); + }, + { + enabled: enabled && !isEntryOpen, + enableOnFormTags: false, + }, + [isEntryOpen, enabled] + ); + // u - toggle unread-only filter useHotkeys( "u", diff --git a/src/lib/hooks/viewPreferences.ts b/src/lib/hooks/viewPreferences.ts index fb7512b7..591b0daf 100644 --- a/src/lib/hooks/viewPreferences.ts +++ b/src/lib/hooks/viewPreferences.ts @@ -16,7 +16,8 @@ export type ViewType = | "saved" | "uncategorized" | "recently-read" - | "best"; + | "best" + | "search"; /** * View preference settings. diff --git a/src/lib/queries/entries-list-input.ts b/src/lib/queries/entries-list-input.ts index fa2925c2..0c257953 100644 --- a/src/lib/queries/entries-list-input.ts +++ b/src/lib/queries/entries-list-input.ts @@ -19,6 +19,7 @@ export type EntryType = "web" | "email" | "saved"; * All optional fields should be explicitly undefined (not omitted) for cache key matching. */ export interface EntriesListInput { + query: string | undefined; subscriptionId: string | undefined; tagId: string | undefined; uncategorized: boolean | undefined; @@ -40,6 +41,7 @@ export interface EntriesListInput { * Filter options passed to buildEntriesListInput. */ export interface EntriesListFilters { + query?: string; subscriptionId?: string; tagId?: string; uncategorized?: boolean; @@ -79,7 +81,9 @@ export function buildEntriesListInput( // This ensures the object structure is identical regardless of where it's constructed. // NOTE: direction is required for tRPC infinite query cache key matching. // Direction depends on sort order: "newest" fetches forward, "oldest" fetches backward. + // When query is provided, backend sorts by relevance, but we still need forward direction. return { + query: filters.query, subscriptionId: filters.subscriptionId, tagId: filters.tagId, uncategorized: filters.uncategorized, @@ -142,6 +146,11 @@ export function getFiltersFromPathname(pathname: string): EntriesListFilters { return { sortBy: "predictedScore" as const }; } + // /search - Full-text search (query comes from URL params, not pathname) + if (pathname === "/search") { + return {}; + } + // /all or default return {}; } @@ -155,7 +164,7 @@ export function getFiltersFromPathname(pathname: string): EntriesListFilters { */ export function getDefaultViewPreferences(pathname: string): EntriesListViewPreferences { return { - unreadOnly: pathname === "/recently-read" ? false : true, + unreadOnly: pathname === "/recently-read" || pathname === "/search" ? false : true, sortOrder: "newest", }; } diff --git a/tests/unit/frontend/entries-list-input.test.ts b/tests/unit/frontend/entries-list-input.test.ts new file mode 100644 index 00000000..9d5decaa --- /dev/null +++ b/tests/unit/frontend/entries-list-input.test.ts @@ -0,0 +1,140 @@ +/** + * Unit tests for entries-list-input utility functions. + * + * Tests getFiltersFromPathname, getDefaultViewPreferences, and buildEntriesListInput. + */ + +import { describe, it, expect } from "vitest"; +import { + getFiltersFromPathname, + getDefaultViewPreferences, + buildEntriesListInput, +} from "@/lib/queries/entries-list-input"; + +describe("getFiltersFromPathname", () => { + it("returns empty filters for /all", () => { + expect(getFiltersFromPathname("/all")).toEqual({}); + }); + + it("returns subscriptionId for /subscription/:id", () => { + expect(getFiltersFromPathname("/subscription/abc-123")).toEqual({ + subscriptionId: "abc-123", + }); + }); + + it("returns tagId for /tag/:tagId", () => { + expect(getFiltersFromPathname("/tag/tag-456")).toEqual({ tagId: "tag-456" }); + }); + + it("returns uncategorized for /tag/uncategorized", () => { + expect(getFiltersFromPathname("/tag/uncategorized")).toEqual({ uncategorized: true }); + }); + + it("returns starredOnly for /starred", () => { + expect(getFiltersFromPathname("/starred")).toEqual({ starredOnly: true }); + }); + + it("returns type saved for /saved", () => { + expect(getFiltersFromPathname("/saved")).toEqual({ type: "saved" }); + }); + + it("returns uncategorized for /uncategorized", () => { + expect(getFiltersFromPathname("/uncategorized")).toEqual({ uncategorized: true }); + }); + + it("returns sortBy readChanged for /recently-read", () => { + expect(getFiltersFromPathname("/recently-read")).toEqual({ sortBy: "readChanged" }); + }); + + it("returns sortBy predictedScore for /best", () => { + expect(getFiltersFromPathname("/best")).toEqual({ sortBy: "predictedScore" }); + }); + + it("returns empty filters for /search", () => { + expect(getFiltersFromPathname("/search")).toEqual({}); + }); + + it("returns empty filters for unknown paths", () => { + expect(getFiltersFromPathname("/unknown")).toEqual({}); + }); +}); + +describe("getDefaultViewPreferences", () => { + it("defaults to unreadOnly: true for most routes", () => { + expect(getDefaultViewPreferences("/all")).toEqual({ + unreadOnly: true, + sortOrder: "newest", + }); + expect(getDefaultViewPreferences("/starred")).toEqual({ + unreadOnly: true, + sortOrder: "newest", + }); + }); + + it("defaults to unreadOnly: false for /recently-read", () => { + expect(getDefaultViewPreferences("/recently-read")).toEqual({ + unreadOnly: false, + sortOrder: "newest", + }); + }); + + it("defaults to unreadOnly: false for /search", () => { + expect(getDefaultViewPreferences("/search")).toEqual({ + unreadOnly: false, + sortOrder: "newest", + }); + }); +}); + +describe("buildEntriesListInput", () => { + it("includes query when provided in filters", () => { + const input = buildEntriesListInput( + { query: "test search" }, + { unreadOnly: false, sortOrder: "newest" } + ); + expect(input.query).toBe("test search"); + }); + + it("sets query to undefined when not provided", () => { + const input = buildEntriesListInput({}, { unreadOnly: true, sortOrder: "newest" }); + expect(input.query).toBeUndefined(); + }); + + it("sets direction to forward for newest sort", () => { + const input = buildEntriesListInput({}, { unreadOnly: true, sortOrder: "newest" }); + expect(input.direction).toBe("forward"); + }); + + it("sets direction to backward for oldest sort", () => { + const input = buildEntriesListInput({}, { unreadOnly: true, sortOrder: "oldest" }); + expect(input.direction).toBe("backward"); + }); + + it("passes all filter fields through", () => { + const input = buildEntriesListInput( + { + query: "search", + subscriptionId: "sub-1", + tagId: "tag-1", + uncategorized: true, + starredOnly: true, + type: "web", + sortBy: "readChanged", + }, + { unreadOnly: false, sortOrder: "oldest" } + ); + expect(input).toEqual({ + query: "search", + subscriptionId: "sub-1", + tagId: "tag-1", + uncategorized: true, + unreadOnly: false, + starredOnly: true, + sortOrder: "oldest", + sortBy: "readChanged", + type: "web", + limit: 10, + direction: "backward", + }); + }); +});