chore: code refactor
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PageHeader — per-page sub-header carrying the page title / breadcrumb.
|
||||
*
|
||||
* The global SiteHeader (mounted in app/layout.tsx) owns the wordmark,
|
||||
* primary nav, search, reading settings, and user menu. PageHeader is now
|
||||
* purely typographic — it renders whatever `children` the page passes and
|
||||
* applies a subtle bordered band so the page's title area has visual weight
|
||||
* separate from the body content.
|
||||
*
|
||||
* Behaviour preserved from the previous (busier) implementation:
|
||||
* • If sessionStorage has `spsays_from_search`, the first <Link> child is
|
||||
* hidden and replaced with a "← Back to search results" button. This
|
||||
* keeps the cross-page-navigation polish that the search page relies on.
|
||||
*
|
||||
* Sticky headers collapse on scroll-down and re-appear on scroll-up
|
||||
* (mobile-address-bar pattern). Long reading pages get back the vertical
|
||||
* real estate without losing the title anchor on demand. Only applies
|
||||
* when `sticky` — a non-sticky header has nothing to slide behind.
|
||||
*
|
||||
* Pages that don't want a sub-header (e.g. the redesigned homepage which
|
||||
* paints its own editorial hero) can simply not render PageHeader.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEARCH_RETURN_KEY = "spsays_from_search";
|
||||
|
||||
export function PageHeader({
|
||||
children,
|
||||
className,
|
||||
bare = false,
|
||||
sticky = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Drop the bordered chrome — useful for pages that want flush title text. */
|
||||
bare?: boolean;
|
||||
/**
|
||||
* Stick to the top below the global SiteHeader. Used by reading pages
|
||||
* so the chapter title stays visible as the user scrolls long content.
|
||||
* The offset is `top-12 sm:top-14` — SiteHeader is `h-12 sm:h-14`, so
|
||||
* these values land the PageHeader flush with its bottom edge with no
|
||||
* visible gap.
|
||||
*/
|
||||
sticky?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Read (and consume) the search-return flag once on first render.
|
||||
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;
|
||||
});
|
||||
|
||||
// Scroll-direction-driven collapse. Hidden when the user is scrolling
|
||||
// down past the first viewport-tenth and revealed again as soon as
|
||||
// they scroll up. Near the top we always show so quick reading doesn't
|
||||
// feel jumpy. Only wired up when `sticky` — that's the only case where
|
||||
// the header has a fixed slot to slide behind.
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const lastScrollYRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!sticky) return;
|
||||
lastScrollYRef.current = window.scrollY;
|
||||
function onScroll() {
|
||||
const y = window.scrollY;
|
||||
const delta = y - lastScrollYRef.current;
|
||||
// Threshold of ~8px filters out scroll-jitter / momentum noise.
|
||||
if (y < 80) setCollapsed(false);
|
||||
else if (delta > 8) setCollapsed(true);
|
||||
else if (delta < -8) setCollapsed(false);
|
||||
lastScrollYRef.current = y;
|
||||
}
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, [sticky]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"px-4 sm:px-6",
|
||||
bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5",
|
||||
sticky &&
|
||||
"sticky top-12 sm:top-14 z-20 transition-transform duration-200 ease-out",
|
||||
// -translate-y-full tucks the band fully behind the SiteHeader
|
||||
// (which sits at a higher z-index), so it visually disappears.
|
||||
sticky && collapsed && "-translate-y-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{fromSearch ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFromSearch(false);
|
||||
router.back();
|
||||
}}
|
||||
className="block text-xs text-muted-foreground hover:text-foreground transition-colors mb-2"
|
||||
>
|
||||
← 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>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user