fix: search component and breadcrumbs fix

This commit is contained in:
gosangam
2026-05-10 21:56:43 +05:30
parent befcc44980
commit 96a83fe1cb
7 changed files with 263 additions and 143 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ export function BooksGrid({ books }: { books: BookInfo[] }) {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
type="text" type="text"
placeholder="Search books..." placeholder="Search books by name..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="pl-9" className="pl-9"
+1 -1
View File
@@ -91,7 +91,7 @@ export function EssaysGrid({ essays }: { essays: EssayInfo[] }) {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
type="text" type="text"
placeholder="Search essays..." placeholder="Search essays by title..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="pl-9" className="pl-9"
+1 -1
View File
@@ -82,7 +82,7 @@ export function OtherDocumentsGrid({
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input <Input
type="text" type="text"
placeholder="Search documents..." placeholder="Search documents by title..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="pl-9" className="pl-9"
+58 -37
View File
@@ -199,9 +199,14 @@ function SearchInner() {
selectedTypes.size === 0 ? "all" : Array.from(selectedTypes).join(","); selectedTypes.size === 0 ? "all" : Array.from(selectedTypes).join(",");
const [data, setData] = useState<SearchResponse | null>(null); const [data, setData] = useState<SearchResponse | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(!!initialQuery);
const debouncedQuery = useDebouncedValue(inputValue, 300); 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 // Sync URL when state changes
const pushUrl = useCallback( const pushUrl = useCallback(
@@ -216,17 +221,25 @@ function SearchInner() {
[router], [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 // Fetch search results
useEffect(() => { useEffect(() => {
const query = debouncedQuery.trim(); const query = debouncedQuery.trim();
if (!query) { if (!query) {
setData(null);
setHasSearched(false);
pushUrl("", urlTypeParam, 1); pushUrl("", urlTypeParam, 1);
return; return;
} }
setHasSearched(true);
setIsLoading(true); setIsLoading(true);
pushUrl(query, urlTypeParam, currentPage); pushUrl(query, urlTypeParam, currentPage);
@@ -264,11 +277,6 @@ function SearchInner() {
return () => controller.abort(); return () => controller.abort();
}, [debouncedQuery, activeTypeParam, urlTypeParam, currentPage, pushUrl]); }, [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 // Auto-focus the input on mount
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
@@ -351,17 +359,17 @@ function SearchInner() {
)} )}
> >
{tab.label} {tab.label}
{data && {effectiveData &&
active && active &&
selectedTypes.size <= 1 && selectedTypes.size <= 1 &&
data.total > 0 && ( effectiveData.total > 0 && (
<span <span
className={cn( className={cn(
"ml-1 inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold leading-none", "ml-1 inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold leading-none",
"bg-primary-foreground/20 text-primary-foreground", "bg-primary-foreground/20 text-primary-foreground",
)} )}
> >
{data.total.toLocaleString()} {effectiveData.total.toLocaleString()}
</span> </span>
)} )}
</button> </button>
@@ -391,33 +399,36 @@ function SearchInner() {
)} )}
{/* No results */} {/* No results */}
{!isLoading && hasSearched && data && data.results.length === 0 && ( {!isLoading &&
<div className="flex flex-col items-center justify-center py-16 text-center"> hasSearched &&
<Search className="size-12 text-muted-foreground/40 mb-4" /> effectiveData &&
<p className="text-lg text-muted-foreground"> effectiveData.results.length === 0 && (
No results found for &ldquo;{debouncedQuery}&rdquo;. <div className="flex flex-col items-center justify-center py-16 text-center">
<br /> <Search className="size-12 text-muted-foreground/40 mb-4" />
<span className="text-sm"> <p className="text-lg text-muted-foreground">
Try a different search term or content type. No results found for &ldquo;{debouncedQuery}&rdquo;.
</span> <br />
</p> <span className="text-sm">
</div> Try a different search term or content type.
)} </span>
</p>
</div>
)}
{/* Results */} {/* Results */}
{!isLoading && data && data.results.length > 0 && ( {!isLoading && effectiveData && effectiveData.results.length > 0 && (
<> <>
{/* Results count + top pagination */} {/* Results count + top pagination */}
<div className="mb-4 flex items-center justify-between flex-wrap gap-3"> <div className="mb-4 flex items-center justify-between flex-wrap gap-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{data.total.toLocaleString()}{" "} {effectiveData.total.toLocaleString()}{" "}
{data.total === 1 ? "result" : "results"} for &ldquo; {effectiveData.total === 1 ? "result" : "results"} for &ldquo;
{data.query}&rdquo; {effectiveData.query}&rdquo;
</p> </p>
{data.total_pages > 1 && ( {effectiveData.total_pages > 1 && (
<PaginationControls <PaginationControls
page={data.page} page={effectiveData.page}
totalPages={data.total_pages} totalPages={effectiveData.total_pages}
onPageChange={handlePageChange} onPageChange={handlePageChange}
compact compact
/> />
@@ -426,7 +437,7 @@ function SearchInner() {
{/* Result list */} {/* Result list */}
<div className="space-y-3"> <div className="space-y-3">
{data.results.map((result, i) => ( {effectiveData.results.map((result, i) => (
<ResultCard <ResultCard
key={`${result.collection}-${result.id || i}`} key={`${result.collection}-${result.id || i}`}
result={result} result={result}
@@ -435,12 +446,12 @@ function SearchInner() {
</div> </div>
{/* Bottom pagination */} {/* Bottom pagination */}
{data.total_pages > 1 && ( {effectiveData.total_pages > 1 && (
<div className="mt-8"> <div className="mt-8">
<PaginationControls <PaginationControls
page={data.page} page={effectiveData.page}
totalPages={data.total_pages} totalPages={effectiveData.total_pages}
total={data.total} total={effectiveData.total}
onPageChange={handlePageChange} onPageChange={handlePageChange}
/> />
</div> </div>
@@ -541,7 +552,17 @@ function ResultCard({ result }: { result: SearchResult }) {
const Icon = config.icon; const Icon = config.icon;
return ( return (
<Link href={result.url} className="block group"> <Link
href={result.url}
className="block group"
onClick={() => {
try {
sessionStorage.setItem("spsays_from_search", "1");
} catch {
/* sessionStorage unavailable */
}
}}
>
<Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20 cursor-pointer"> <Card className="transition-all group-hover:shadow-md group-hover:ring-foreground/20 cursor-pointer">
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col gap-2">
{/* Collection badge */} {/* Collection badge */}
+125 -94
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState, useRef, useEffect, useCallback } from "react"; import { useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion"; import { motion } from "framer-motion";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { cn } from "@/lib/utils"; 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 * into a full search input on click. Typing a query and pressing Enter
* (or clicking the search icon again) navigates to /search?q=... * (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. * Pressing Escape or clicking outside collapses the input.
* Keyboard shortcut: "/" focuses the search from anywhere on the page. * Keyboard shortcut: "/" focuses the search from anywhere on the page.
*/ */
export function HeaderSearchButton() { /** Map the current pathname to the search `type` filter value. */
const [expanded, setExpanded] = useState(false); function searchTypeFromPath(pathname: string): string | null {
const [query, setQuery] = useState(""); 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<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
// Focus input when expanded // Focus input when expanded
useEffect(() => { useEffect(() => {
if (expanded && inputRef.current) { if (expanded && inputRef.current) {
// Small delay to let the animation start before focusing const timer = setTimeout(() => inputRef.current?.focus(), 80);
const timer = setTimeout(() => inputRef.current?.focus(), 50);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [expanded]); }, [expanded]);
@@ -40,18 +60,17 @@ export function HeaderSearchButton() {
containerRef.current && containerRef.current &&
!containerRef.current.contains(e.target as Node) !containerRef.current.contains(e.target as Node)
) { ) {
setExpanded(false); onExpandedChange(false);
} }
} }
document.addEventListener("mousedown", handleClick); document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick);
}, [expanded]); }, [expanded, onExpandedChange]);
// Global "/" shortcut to open search // Global "/" shortcut to open search
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
// Don't trigger if user is typing in an input/textarea
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if ( if (
target.tagName === "INPUT" || target.tagName === "INPUT" ||
@@ -64,25 +83,29 @@ export function HeaderSearchButton() {
if (e.key === "/") { if (e.key === "/") {
e.preventDefault(); e.preventDefault();
setExpanded(true); onExpandedChange(true);
} }
} }
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, []); }, [onExpandedChange]);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
const trimmed = query.trim(); const trimmed = queryRef.current.trim();
if (trimmed) { const params = new URLSearchParams();
router.push(`/search?q=${encodeURIComponent(trimmed)}`); if (trimmed) params.set("q", trimmed);
setExpanded(false);
setQuery(""); // Pre-select the search filter matching the current section
} else { const type = searchTypeFromPath(pathname);
router.push("/search"); if (type) params.set("type", type);
setExpanded(false);
} const qs = params.toString();
}, [query, router]); router.push(`/search${qs ? `?${qs}` : ""}`);
onExpandedChange(false);
queryRef.current = "";
if (inputRef.current) inputRef.current.value = "";
}, [router, onExpandedChange, pathname]);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@@ -91,83 +114,91 @@ export function HeaderSearchButton() {
handleSubmit(); handleSubmit();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
setExpanded(false); onExpandedChange(false);
setQuery(""); 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 ( return (
<div ref={containerRef} className="relative flex items-center"> <div
<AnimatePresence mode="wait"> ref={containerRef}
{expanded ? ( className={cn(
<motion.div "relative flex items-center",
key="search-input" expanded && "max-md:flex-1 max-md:min-w-0",
initial={{ opacity: 0 }} )}
animate={{ opacity: 1 }} >
exit={{ opacity: 0 }} {expanded ? (
transition={{ duration: 0.15 }} <motion.div
className={cn( key="search-input"
// Mobile: cover the entire header (absolutely positioned) initial={{ width: 36, opacity: 0 }}
"max-md:fixed max-md:inset-x-4 max-md:top-3 max-md:z-30", animate={{ width: "100%", opacity: 1 }}
// Desktop: inline at 280px wide transition={{
"md:relative md:w-[280px]", width: { type: "spring", stiffness: 400, damping: 30 },
"flex items-center gap-2 h-9 rounded-lg border border-border bg-background px-3 overflow-hidden", opacity: { duration: 0.15 },
"max-md:shadow-lg", }}
)} className={cn(
> "flex items-center gap-2 h-12 md:h-9 rounded-lg border border-border bg-background px-3 overflow-hidden",
<button "max-md:w-full",
type="button" "md:w-70",
onClick={handleSubmit} )}
aria-label="Search" >
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors" <button
>
<Search className="size-4" />
</button>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => {
setExpanded(false);
setQuery("");
}}
aria-label="Close search"
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-3.5" />
</button>
</motion.div>
) : (
<motion.button
key="search-icon"
initial={{ opacity: 0.8, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0.8, scale: 0.9 }}
transition={{ duration: 0.15 }}
type="button" type="button"
onClick={() => setExpanded(true)} onClick={handleSubmit}
aria-label="Search (press /)" aria-label="Search"
title="Search (press /)" className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
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",
)}
> >
<Search className="size-[18px]" /> <Search className="size-5 md:size-4" />
</motion.button> </button>
)} <input
</AnimatePresence> ref={inputRef}
type="text"
defaultValue=""
onChange={(e) => {
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"
/>
<button
type="button"
onClick={collapse}
aria-label="Close search"
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-3.5" />
</button>
</motion.div>
) : (
<motion.button
key="search-icon"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.12 }}
type="button"
onClick={() => 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",
)}
>
<Search className="size-4.5" />
</motion.button>
)}
</div> </div>
); );
} }
+68 -4
View File
@@ -1,7 +1,13 @@
"use client";
import { useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { ReadingSettings } from "@/components/reading-settings"; import { ReadingSettings } from "@/components/reading-settings";
import { HeaderSearchButton } from "@/components/header-search"; import { HeaderSearchButton } from "@/components/header-search";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const SEARCH_RETURN_KEY = "spsays_from_search";
export function PageHeader({ export function PageHeader({
children, children,
sticky = false, sticky = false,
@@ -11,6 +17,29 @@ export function PageHeader({
sticky?: boolean; sticky?: boolean;
className?: string; 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 ( return (
<header <header
className={cn( className={cn(
@@ -20,10 +49,45 @@ export function PageHeader({
)} )}
> >
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4"> <div className="max-w-5xl mx-auto flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">{children}</div> {/* Title / logo area — hidden on mobile when search is active */}
<div className="shrink-0 pt-1 flex items-center gap-1.5"> <div
<HeaderSearchButton /> className={cn("flex-1 min-w-0", searchExpanded && "max-md:hidden")}
<ReadingSettings /> >
{fromSearch ? (
<>
<button
type="button"
onClick={() => {
setFromSearch(false);
router.back();
}}
className="block text-sm text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Back to search results
</button>
{/* Hide the page's own breadcrumb link (always the first <a>) */}
<div className="[&>a:first-child]:hidden">{children}</div>
</>
) : (
children
)}
</div>
{/* Actions — expands to fill width on mobile when search is active */}
<div
className={cn(
"shrink-0 pt-1 flex items-center gap-1.5",
searchExpanded && "max-md:flex-1 max-md:min-w-0",
)}
>
<HeaderSearchButton
expanded={searchExpanded}
onExpandedChange={setSearchExpanded}
/>
{/* Hide reading settings on mobile when search is active */}
<div className={cn(searchExpanded && "max-md:hidden")}>
<ReadingSettings />
</div>
</div> </div>
</div> </div>
</header> </header>
+9 -5
View File
@@ -188,16 +188,20 @@ export function ReadingSettings() {
return ( return (
<div className="relative"> <div className="relative">
<Button <button
ref={buttonRef as React.Ref<HTMLButtonElement>} ref={buttonRef as React.Ref<HTMLButtonElement>}
variant="ghost"
size="icon-sm"
aria-label="Reading settings" aria-label="Reading settings"
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
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",
)}
> >
<Settings className="size-4" /> <Settings className="size-5 md:size-4" />
</Button> </button>
{open && ( {open && (
<div <div