feat: offline content
This commit is contained in:
@@ -27,18 +27,60 @@ 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.
|
||||
@@ -52,6 +94,16 @@ export function PageHeader({
|
||||
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;
|
||||
@@ -162,8 +214,8 @@ export function PageHeader({
|
||||
children
|
||||
)}
|
||||
|
||||
{/* Copy-link-to-this-page control, anchored at the header's bottom. */}
|
||||
<div className="mt-3">
|
||||
{/* 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}
|
||||
@@ -183,6 +235,9 @@ export function PageHeader({
|
||||
)}
|
||||
{copied ? "Link copied" : "Copy link to this page"}
|
||||
</button>
|
||||
{resolvedOffline ? (
|
||||
<SaveOfflineButton descriptor={resolvedOffline} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user