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" />
<Input
type="text"
placeholder="Search books..."
placeholder="Search books by name..."
value={query}
onChange={(e) => setQuery(e.target.value)}
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" />
<Input
type="text"
placeholder="Search essays..."
placeholder="Search essays by title..."
value={query}
onChange={(e) => setQuery(e.target.value)}
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" />
<Input
type="text"
placeholder="Search documents..."
placeholder="Search documents by title..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
+58 -37
View File
@@ -199,9 +199,14 @@ function SearchInner() {
selectedTypes.size === 0 ? "all" : Array.from(selectedTypes).join(",");
const [data, setData] = useState<SearchResponse | null>(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 && (
<span
className={cn(
"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",
)}
>
{data.total.toLocaleString()}
{effectiveData.total.toLocaleString()}
</span>
)}
</button>
@@ -391,33 +399,36 @@ function SearchInner() {
)}
{/* No results */}
{!isLoading && hasSearched && data && data.results.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Search className="size-12 text-muted-foreground/40 mb-4" />
<p className="text-lg text-muted-foreground">
No results found for &ldquo;{debouncedQuery}&rdquo;.
<br />
<span className="text-sm">
Try a different search term or content type.
</span>
</p>
</div>
)}
{!isLoading &&
hasSearched &&
effectiveData &&
effectiveData.results.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Search className="size-12 text-muted-foreground/40 mb-4" />
<p className="text-lg text-muted-foreground">
No results found for &ldquo;{debouncedQuery}&rdquo;.
<br />
<span className="text-sm">
Try a different search term or content type.
</span>
</p>
</div>
)}
{/* Results */}
{!isLoading && data && data.results.length > 0 && (
{!isLoading && effectiveData && effectiveData.results.length > 0 && (
<>
{/* Results count + top pagination */}
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
<p className="text-sm text-muted-foreground">
{data.total.toLocaleString()}{" "}
{data.total === 1 ? "result" : "results"} for &ldquo;
{data.query}&rdquo;
{effectiveData.total.toLocaleString()}{" "}
{effectiveData.total === 1 ? "result" : "results"} for &ldquo;
{effectiveData.query}&rdquo;
</p>
{data.total_pages > 1 && (
{effectiveData.total_pages > 1 && (
<PaginationControls
page={data.page}
totalPages={data.total_pages}
page={effectiveData.page}
totalPages={effectiveData.total_pages}
onPageChange={handlePageChange}
compact
/>
@@ -426,7 +437,7 @@ function SearchInner() {
{/* Result list */}
<div className="space-y-3">
{data.results.map((result, i) => (
{effectiveData.results.map((result, i) => (
<ResultCard
key={`${result.collection}-${result.id || i}`}
result={result}
@@ -435,12 +446,12 @@ function SearchInner() {
</div>
{/* Bottom pagination */}
{data.total_pages > 1 && (
{effectiveData.total_pages > 1 && (
<div className="mt-8">
<PaginationControls
page={data.page}
totalPages={data.total_pages}
total={data.total}
page={effectiveData.page}
totalPages={effectiveData.total_pages}
total={effectiveData.total}
onPageChange={handlePageChange}
/>
</div>
@@ -541,7 +552,17 @@ function ResultCard({ result }: { result: SearchResult }) {
const Icon = config.icon;
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">
<CardContent className="flex flex-col gap-2">
{/* Collection badge */}
+125 -94
View File
@@ -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<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative flex items-center">
<AnimatePresence mode="wait">
{expanded ? (
<motion.div
key="search-input"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={cn(
// Mobile: cover the entire header (absolutely positioned)
"max-md:fixed max-md:inset-x-4 max-md:top-3 max-md:z-30",
// Desktop: inline at 280px wide
"md:relative md:w-[280px]",
"flex items-center gap-2 h-9 rounded-lg border border-border bg-background px-3 overflow-hidden",
"max-md:shadow-lg",
)}
>
<button
type="button"
onClick={handleSubmit}
aria-label="Search"
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<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 }}
<div
ref={containerRef}
className={cn(
"relative flex items-center",
expanded && "max-md:flex-1 max-md:min-w-0",
)}
>
{expanded ? (
<motion.div
key="search-input"
initial={{ width: 36, opacity: 0 }}
animate={{ width: "100%", opacity: 1 }}
transition={{
width: { type: "spring", stiffness: 400, damping: 30 },
opacity: { duration: 0.15 },
}}
className={cn(
"flex items-center gap-2 h-12 md:h-9 rounded-lg border border-border bg-background px-3 overflow-hidden",
"max-md:w-full",
"md:w-70",
)}
>
<button
type="button"
onClick={() => setExpanded(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",
)}
onClick={handleSubmit}
aria-label="Search"
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<Search className="size-[18px]" />
</motion.button>
)}
</AnimatePresence>
<Search className="size-5 md:size-4" />
</button>
<input
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>
);
}
+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 { 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 (
<header
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="flex-1 min-w-0">{children}</div>
<div className="shrink-0 pt-1 flex items-center gap-1.5">
<HeaderSearchButton />
<ReadingSettings />
{/* Title / logo area — hidden on mobile when search is active */}
<div
className={cn("flex-1 min-w-0", searchExpanded && "max-md:hidden")}
>
{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>
</header>
+9 -5
View File
@@ -188,16 +188,20 @@ export function ReadingSettings() {
return (
<div className="relative">
<Button
<button
ref={buttonRef as React.Ref<HTMLButtonElement>}
variant="ghost"
size="icon-sm"
aria-label="Reading settings"
aria-expanded={open}
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" />
</Button>
<Settings className="size-5 md:size-4" />
</button>
{open && (
<div