diff --git a/app/books/books-grid.tsx b/app/books/books-grid.tsx index 803b9d93..f82476ec 100644 --- a/app/books/books-grid.tsx +++ b/app/books/books-grid.tsx @@ -86,7 +86,7 @@ export function BooksGrid({ books }: { books: BookInfo[] }) { setQuery(e.target.value)} className="pl-9" diff --git a/app/essays/essays-grid.tsx b/app/essays/essays-grid.tsx index af6f5c26..a50b8c18 100644 --- a/app/essays/essays-grid.tsx +++ b/app/essays/essays-grid.tsx @@ -91,7 +91,7 @@ export function EssaysGrid({ essays }: { essays: EssayInfo[] }) { setQuery(e.target.value)} className="pl-9" diff --git a/app/other-documents/other-documents-grid.tsx b/app/other-documents/other-documents-grid.tsx index b2902e3d..bd3d88e6 100644 --- a/app/other-documents/other-documents-grid.tsx +++ b/app/other-documents/other-documents-grid.tsx @@ -82,7 +82,7 @@ export function OtherDocumentsGrid({ setQuery(e.target.value)} className="pl-9" diff --git a/app/search/search-content.tsx b/app/search/search-content.tsx index 5eb14609..cf424b9c 100644 --- a/app/search/search-content.tsx +++ b/app/search/search-content.tsx @@ -199,9 +199,14 @@ function SearchInner() { selectedTypes.size === 0 ? "all" : Array.from(selectedTypes).join(","); const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [hasSearched, setHasSearched] = useState(!!initialQuery); const debouncedQuery = useDebouncedValue(inputValue, 300); + const isQueryEmpty = !debouncedQuery.trim(); + + // When the query is empty, derive these as "no results" during render + // instead of setting state inside an effect. + const effectiveData = isQueryEmpty ? null : data; + const hasSearched = isQueryEmpty ? false : !!debouncedQuery.trim(); // Sync URL when state changes const pushUrl = useCallback( @@ -216,17 +221,25 @@ function SearchInner() { [router], ); + // Reset page to 1 when query or type changes. + // Uses the "compare previous value during render" pattern + // recommended by React instead of a setState-in-effect. + const [prevQuery, setPrevQuery] = useState(debouncedQuery); + const [prevType, setPrevType] = useState(activeTypeParam); + if (prevQuery !== debouncedQuery || prevType !== activeTypeParam) { + setPrevQuery(debouncedQuery); + setPrevType(activeTypeParam); + setCurrentPage(1); + } + // Fetch search results useEffect(() => { const query = debouncedQuery.trim(); if (!query) { - setData(null); - setHasSearched(false); pushUrl("", urlTypeParam, 1); return; } - setHasSearched(true); setIsLoading(true); pushUrl(query, urlTypeParam, currentPage); @@ -264,11 +277,6 @@ function SearchInner() { return () => controller.abort(); }, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, pushUrl]); - // Reset page to 1 when query or type changes - useEffect(() => { - setCurrentPage(1); - }, [debouncedQuery, activeTypeParam]); - // Auto-focus the input on mount useEffect(() => { inputRef.current?.focus(); @@ -351,17 +359,17 @@ function SearchInner() { )} > {tab.label} - {data && + {effectiveData && active && selectedTypes.size <= 1 && - data.total > 0 && ( + effectiveData.total > 0 && ( - {data.total.toLocaleString()} + {effectiveData.total.toLocaleString()} )} @@ -391,33 +399,36 @@ function SearchInner() { )} {/* No results */} - {!isLoading && hasSearched && data && data.results.length === 0 && ( -
- -

- No results found for “{debouncedQuery}”. -
- - Try a different search term or content type. - -

-
- )} + {!isLoading && + hasSearched && + effectiveData && + effectiveData.results.length === 0 && ( +
+ +

+ No results found for “{debouncedQuery}”. +
+ + Try a different search term or content type. + +

+
+ )} {/* Results */} - {!isLoading && data && data.results.length > 0 && ( + {!isLoading && effectiveData && effectiveData.results.length > 0 && ( <> {/* Results count + top pagination */}

- {data.total.toLocaleString()}{" "} - {data.total === 1 ? "result" : "results"} for “ - {data.query}” + {effectiveData.total.toLocaleString()}{" "} + {effectiveData.total === 1 ? "result" : "results"} for “ + {effectiveData.query}”

- {data.total_pages > 1 && ( + {effectiveData.total_pages > 1 && ( @@ -426,7 +437,7 @@ function SearchInner() { {/* Result list */}
- {data.results.map((result, i) => ( + {effectiveData.results.map((result, i) => ( {/* Bottom pagination */} - {data.total_pages > 1 && ( + {effectiveData.total_pages > 1 && (
@@ -541,7 +552,17 @@ function ResultCard({ result }: { result: SearchResult }) { const Icon = config.icon; return ( - + { + try { + sessionStorage.setItem("spsays_from_search", "1"); + } catch { + /* sessionStorage unavailable */ + } + }} + > {/* Collection badge */} diff --git a/components/header-search.tsx b/components/header-search.tsx index af70af9b..0a941af5 100644 --- a/components/header-search.tsx +++ b/components/header-search.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useRef, useEffect, useCallback } from "react"; -import { useRouter } from "next/navigation"; -import { motion, AnimatePresence } from "framer-motion"; +import { useRef, useEffect, useCallback } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { motion } from "framer-motion"; import { Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -11,22 +11,42 @@ import { cn } from "@/lib/utils"; * into a full search input on click. Typing a query and pressing Enter * (or clicking the search icon again) navigates to /search?q=... * - * The expansion/collapse is animated with framer-motion. + * State is controlled by the parent (PageHeader) so the header can + * hide/show adjacent elements (logo, reading settings) on mobile. + * * Pressing Escape or clicking outside collapses the input. * Keyboard shortcut: "/" focuses the search from anywhere on the page. */ -export function HeaderSearchButton() { - const [expanded, setExpanded] = useState(false); - const [query, setQuery] = useState(""); +/** Map the current pathname to the search `type` filter value. */ +function searchTypeFromPath(pathname: string): string | null { + if (pathname.startsWith("/books")) return "books"; + if (pathname.startsWith("/letters")) return "letters"; + if (pathname.startsWith("/essays")) return "essays"; + if (pathname.startsWith("/transcriptions")) return "transcriptions"; + if (pathname.startsWith("/glossary")) return "glossary"; + if (pathname.startsWith("/journals")) return "journals"; + if (pathname.startsWith("/magazines")) return "magazines"; + if (pathname.startsWith("/other-documents")) return "other_documents"; + return null; // homepage, search page, about, etc. → "all" +} + +export function HeaderSearchButton({ + expanded, + onExpandedChange, +}: { + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; +}) { + const queryRef = useRef(""); const inputRef = useRef(null); const containerRef = useRef(null); const router = useRouter(); + const pathname = usePathname(); // Focus input when expanded useEffect(() => { if (expanded && inputRef.current) { - // Small delay to let the animation start before focusing - const timer = setTimeout(() => inputRef.current?.focus(), 50); + const timer = setTimeout(() => inputRef.current?.focus(), 80); return () => clearTimeout(timer); } }, [expanded]); @@ -40,18 +60,17 @@ export function HeaderSearchButton() { containerRef.current && !containerRef.current.contains(e.target as Node) ) { - setExpanded(false); + onExpandedChange(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); - }, [expanded]); + }, [expanded, onExpandedChange]); // Global "/" shortcut to open search useEffect(() => { function handleKeyDown(e: KeyboardEvent) { - // Don't trigger if user is typing in an input/textarea const target = e.target as HTMLElement; if ( target.tagName === "INPUT" || @@ -64,25 +83,29 @@ export function HeaderSearchButton() { if (e.key === "/") { e.preventDefault(); - setExpanded(true); + onExpandedChange(true); } } document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, []); + }, [onExpandedChange]); const handleSubmit = useCallback(() => { - const trimmed = query.trim(); - if (trimmed) { - router.push(`/search?q=${encodeURIComponent(trimmed)}`); - setExpanded(false); - setQuery(""); - } else { - router.push("/search"); - setExpanded(false); - } - }, [query, router]); + const trimmed = queryRef.current.trim(); + const params = new URLSearchParams(); + if (trimmed) params.set("q", trimmed); + + // Pre-select the search filter matching the current section + const type = searchTypeFromPath(pathname); + if (type) params.set("type", type); + + const qs = params.toString(); + router.push(`/search${qs ? `?${qs}` : ""}`); + onExpandedChange(false); + queryRef.current = ""; + if (inputRef.current) inputRef.current.value = ""; + }, [router, onExpandedChange, pathname]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -91,83 +114,91 @@ export function HeaderSearchButton() { handleSubmit(); } else if (e.key === "Escape") { e.preventDefault(); - setExpanded(false); - setQuery(""); + onExpandedChange(false); + queryRef.current = ""; + if (inputRef.current) inputRef.current.value = ""; } }, - [handleSubmit], + [handleSubmit, onExpandedChange], ); + const collapse = useCallback(() => { + onExpandedChange(false); + queryRef.current = ""; + if (inputRef.current) inputRef.current.value = ""; + }, [onExpandedChange]); + return ( -
- - {expanded ? ( - - - setQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Search..." - className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0" - /> - - - ) : ( - + {expanded ? ( + + + { + queryRef.current = e.target.value; + }} + onKeyDown={handleKeyDown} + placeholder="Search..." + className="flex-1 bg-transparent text-md md:text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0" + /> + + + ) : ( + onExpandedChange(true)} + aria-label="Search (press /)" + title="Search (press /)" + className={cn( + "size-9 inline-flex items-center justify-center rounded-lg", + "text-muted-foreground hover:text-foreground hover:bg-muted", + "transition-colors outline-none", + "focus-visible:ring-2 focus-visible:ring-ring", + )} + > + + + )}
); } diff --git a/components/page-header.tsx b/components/page-header.tsx index e23c63ce..facf0e24 100644 --- a/components/page-header.tsx +++ b/components/page-header.tsx @@ -1,7 +1,13 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, usePathname } from "next/navigation"; import { ReadingSettings } from "@/components/reading-settings"; import { HeaderSearchButton } from "@/components/header-search"; import { cn } from "@/lib/utils"; +const SEARCH_RETURN_KEY = "spsays_from_search"; + export function PageHeader({ children, sticky = false, @@ -11,6 +17,29 @@ export function PageHeader({ sticky?: boolean; className?: string; }) { + const [searchExpanded, setSearchExpanded] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + + // Read (and consume) the search-return flag once on first render. + // The lazy initialiser runs exactly once; on the server + // (typeof window === "undefined") it returns false. + const [fromSearch, setFromSearch] = useState(() => { + if (typeof window === "undefined") return false; + try { + if ( + sessionStorage.getItem(SEARCH_RETURN_KEY) === "1" && + !pathname.startsWith("/search") + ) { + sessionStorage.removeItem(SEARCH_RETURN_KEY); + return true; + } + } catch { + // sessionStorage unavailable + } + return false; + }); + return (
-
{children}
-
- - + {/* Title / logo area — hidden on mobile when search is active */} +
+ {fromSearch ? ( + <> + + {/* Hide the page's own breadcrumb link (always the first ) */} +
{children}
+ + ) : ( + children + )} +
+ + {/* Actions — expands to fill width on mobile when search is active */} +
+ + {/* Hide reading settings on mobile when search is active */} +
+ +
diff --git a/components/reading-settings.tsx b/components/reading-settings.tsx index b813089f..1a8e61c0 100644 --- a/components/reading-settings.tsx +++ b/components/reading-settings.tsx @@ -188,16 +188,20 @@ export function ReadingSettings() { return (
- + + {open && (