feat: offline content

This commit is contained in:
gosangam
2026-07-02 21:32:22 +05:30
parent 0b67a1142f
commit 380c23e469
13 changed files with 969 additions and 14 deletions
+57 -2
View File
@@ -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>