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",
+ });
+ });
+});