fix: search component and breadcrumbs fix
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 “{debouncedQuery}”.
|
<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 “{debouncedQuery}”.
|
||||||
</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 “
|
{effectiveData.total === 1 ? "result" : "results"} for “
|
||||||
{data.query}”
|
{effectiveData.query}”
|
||||||
</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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>
|
||||||
|
← 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user