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
10 changes: 10 additions & 0 deletions src/app/(app)/AppLayoutContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
CloseIcon,
MenuIcon,
PlusIcon,
SearchIcon,
UserIcon,
ChevronDownIcon,
} from "@/components/ui/icon-button";
Expand Down Expand Up @@ -95,6 +96,15 @@ export function AppLayoutContent({ initialCursors }: AppLayoutContentProps) {
}
headerRight={
<div className="flex items-center gap-2">
{/* Search button */}
<ClientLink
href="/search"
className="flex min-h-[40px] items-center justify-center rounded-md px-2 text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 active:bg-zinc-200 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300 dark:active:bg-zinc-700"
aria-label="Search entries"
>
<SearchIcon className="h-5 w-5" />
</ClientLink>

{/* Subscribe button */}
<ClientLink
href="/subscribe"
Expand Down
16 changes: 16 additions & 0 deletions src/app/(app)/search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Search Page
*
* Prefetches entry data for /search route with full-text search query.
* AppRouter handles rendering.
*/

import { EntryListPage } from "@/components/entries/EntryListPage";

interface SearchPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

export default function SearchPage({ searchParams }: SearchPageProps) {
return <EntryListPage pathname="/search" searchParams={searchParams} />;
}
12 changes: 11 additions & 1 deletion src/components/entries/EntryListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 });
}
Expand Down
61 changes: 53 additions & 8 deletions src/components/entries/UnifiedEntriesContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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";
Expand All @@ -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(() => {
Expand Down Expand Up @@ -445,11 +479,15 @@ function UnifiedEntriesContentInner() {
}

// Title has its own Suspense boundary with smart fallback that uses cache
const titleSlot = (
<Suspense fallback={<TitleFallback routeInfo={routeInfo} />}>
<EntryListTitle routeInfo={routeInfo} />
</Suspense>
);
// Search page uses SearchInput instead of a text title
const titleSlot =
routeInfo.viewId === "search" ? (
<SearchInput />
) : (
<Suspense fallback={<TitleFallback routeInfo={routeInfo} />}>
<EntryListTitle routeInfo={routeInfo} />
</Suspense>
);

// Entry content - has its own internal Suspense boundary
const entryContentSlot = openEntryId ? (
Expand All @@ -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 ? (
<EntryListEmpty
message={SEARCH_EMPTY_PROMPT}
icon={<SearchIcon className="mb-4 h-12 w-12 text-zinc-400 dark:text-zinc-500" />}
/>
) : (
<Suspense
fallback={
<EntryListFallback
Expand Down
1 change: 1 addition & 0 deletions src/components/keyboard/KeyboardShortcutsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const SHORTCUT_SECTIONS: ShortcutSection[] = [
{ keys: ["o"], description: "Open selected entry" },
{ keys: ["Enter"], description: "Open selected entry" },
{ keys: ["Escape"], description: "Close entry / deselect" },
{ keys: ["/"], description: "Search entries" },
{ keys: ["g", "a"], description: "Go to All items" },
{ keys: ["g", "s"], description: "Go to Starred items" },
{ keys: ["g", "l"], description: "Go to Saved items" },
Expand Down
105 changes: 105 additions & 0 deletions src/components/search/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* SearchInput Component
*
* Search input for the /search page. Debounces input and updates the URL
* query parameter `q` to drive full-text search via entries.list.
*
* Autofocuses on mount. Keyboard shortcuts are disabled while focused.
*/

"use client";

import { useState, useEffect, useRef, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { clientReplace } from "@/lib/navigation";
import { SearchIcon, CloseIcon } from "@/components/ui/icon-button";
import { useKeyboardShortcutsContext } from "@/components/keyboard/KeyboardShortcutsProvider";

const DEBOUNCE_MS = 300;

export function SearchInput() {
const searchParams = useSearchParams();
const urlQuery = searchParams?.get("q") ?? "";
const [inputValue, setInputValue] = useState(urlQuery);
const inputRef = useRef<HTMLInputElement>(null);
const { setEnabled: setKeyboardShortcutsEnabled } = useKeyboardShortcutsContext();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The manual toggling of keyboard shortcuts is redundant. The useKeyboardShortcuts hook in this project already uses enableOnFormTags: false for all its hotkeys, which automatically disables them when an input or search field is focused. Removing this manual state management simplifies the component.

Suggested change
const { setEnabled: setKeyboardShortcutsEnabled } = useKeyboardShortcutsContext();
const inputRef = useRef<HTMLInputElement>(null);


// 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);
}
}
Comment on lines +33 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Mutating a ref during the render phase is a side effect that violates React's rules. This can lead to unpredictable behavior in concurrent rendering. Instead, use the recommended pattern for adjusting state when a prop (or URL parameter) changes by tracking the previous value in state.

Suggested change
if (urlQuery !== lastWrittenRef.current) {
lastWrittenRef.current = urlQuery;
if (urlQuery !== inputValue) {
setInputValue(urlQuery);
}
}
const [prevUrlQuery, setPrevUrlQuery] = useState(urlQuery);
if (urlQuery !== prevUrlQuery) {
setPrevUrlQuery(urlQuery);
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();
}, []);
Comment on lines +74 to +77
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The clear action should update the URL immediately to provide instant feedback to the user. Currently, it relies on the 300ms debounce timer, which causes the previous search results to linger after the input is cleared.

  const handleClear = useCallback(() => {
    setInputValue("");
    const params = new URLSearchParams(searchParams?.toString() ?? "");
    params.delete("q");
    const queryString = params.toString();
    const url = queryString ? "/search?" + queryString : "/search";
    clientReplace(url);
    inputRef.current?.focus();
  }, [searchParams]);


return (
<div className="relative flex-1">
<SearchIcon className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-zinc-400 dark:text-zinc-500" />
<input
ref={inputRef}
type="search"
value={inputValue}
onChange={(e) => 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 && (
<button
type="button"
onClick={handleClear}
className="absolute top-1/2 right-2 -translate-y-1/2 rounded p-0.5 text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300"
aria-label="Clear search"
>
<CloseIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
16 changes: 16 additions & 0 deletions src/components/ui/icon-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
);
}

/**
* Question circle icon (help)
*/
Expand Down
14 changes: 12 additions & 2 deletions src/lib/hooks/useEntriesListInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]);
}
14 changes: 14 additions & 0 deletions src/lib/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/lib/hooks/viewPreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export type ViewType =
| "saved"
| "uncategorized"
| "recently-read"
| "best";
| "best"
| "search";

/**
* View preference settings.
Expand Down
Loading
Loading