"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 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 { QuizButton } from "@/components/quiz/quiz-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, quiz = true, }: { 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; /** * Show this page's quiz button (if it has a published quiz) under the * title. On by default for content pages; a page that places the button * itself — the transcript, beside its audio player — turns this off so it * never appears twice. */ quiz?: 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 | 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 (
{fromSearch ? ( <> {/* Hide the page's own breadcrumb link (always the first ) */}
{children}
) : ( children )} {/* Page-level controls, anchored at the header's bottom. */}
{/* First, so it reads as belonging to the title above it. Renders nothing on pages without a published quiz. */} {quiz && isContentPath(pathname) ? : null} {resolvedOffline ? ( ) : null}
); }