Files
2026-07-02 21:32:22 +05:30

246 lines
8.9 KiB
TypeScript

"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 { Check, Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { SaveOfflineButton } from "@/components/layout/save-offline-button";
import type { OfflineDescriptor } from "@/lib/offline";
const SEARCH_RETURN_KEY = "spsays_from_search";
// Prefixes of the readable content sections. A `sticky` PageHeader on one of
// these routes is a reading page → it gets a Save-offline button automatically
// (pages can still pass an explicit `offline` prop, e.g. transcripts adding
// their audio URL).
const CONTENT_PREFIXES = [
"/books/",
"/letters/",
"/magazines/",
"/transcriptions/",
"/journals/",
"/essays/",
"/other-documents/",
];
function isContentPath(pathname: string): boolean {
return CONTENT_PREFIXES.some((p) => pathname.startsWith(p));
}
function deriveOfflineType(pathname: string): OfflineDescriptor["type"] {
if (pathname.startsWith("/letters/")) return "letter";
if (pathname.startsWith("/magazines/")) return "magazine";
if (pathname.startsWith("/transcriptions/")) return "transcript";
if (pathname.startsWith("/journals/")) return "journal";
if (pathname.startsWith("/essays/")) return "essay";
if (pathname.startsWith("/other-documents/")) return "document";
if (pathname.startsWith("/books/")) {
// A trailing numeric segment is a verse/text; otherwise a prose chapter.
const last = pathname.split("/").filter(Boolean).pop() ?? "";
return /^\d+$/.test(last) ? "verse" : "prose";
}
return "document";
}
export function PageHeader({
children,
className,
bare = false,
sticky = false,
offline,
}: {
children: React.ReactNode;
/** Drop the bordered chrome — useful for pages that want flush title text. */
bare?: boolean;
/**
* When set, a "Save offline" button appears beside "Copy link to this page"
* so the reader can keep this page (and, for transcripts, its audio) for
* offline use. Omit on pages that shouldn't be saveable (static/legal pages).
*/
offline?: OfflineDescriptor;
/**
* 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();
// Reading pages (sticky header on a content route) get a Save-offline button
// automatically; a page can pass `offline` explicitly to add an audio URL or
// a precise title/subtitle. An empty derived title resolves to document.title
// at save time (see lib/offline saveOffline).
const resolvedOffline: OfflineDescriptor | undefined =
offline ??
(sticky && isContentPath(pathname)
? { type: deriveOfflineType(pathname), title: "" }
: undefined);
// 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]);
// "Copy link to this page" — copies the full, unshortened absolute URL of
// the active page. Deliberately bypasses useCopyFeedback (which rewrites
// `?hl=` highlight URLs into short /share links); here we want the literal
// page URL. The write is synchronous within the click so a single tap works.
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
};
}, []);
async function copyPageLink() {
const url = window.location.href;
try {
await navigator.clipboard.writeText(url);
} catch {
// Fallback for browsers without the async clipboard API.
const ta = document.createElement("textarea");
ta.value = url;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand("copy");
} catch {
// silent fail
}
document.body.removeChild(ta);
}
setCopied(true);
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
}
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"
>
&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
)}
{/* Page-level controls, anchored at the header's bottom. */}
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={copyPageLink}
aria-label="Copy link to this page"
title="Copy link to this page"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs transition-colors",
copied
? "text-foreground border-primary/40"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{copied ? (
<Check className="size-3.5 text-primary" />
) : (
<Link2 className="size-3.5" />
)}
{copied ? "Link copied" : "Copy link to this page"}
</button>
{resolvedOffline ? (
<SaveOfflineButton descriptor={resolvedOffline} />
) : null}
</div>
</div>
</header>
);
}