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
+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