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>
+124
View File
@@ -0,0 +1,124 @@
"use client";
/**
* SaveOfflineButton — sits beside "Copy link to this page" in PageHeader.
*
* Saves the current page (and, for transcripts, its audio) for offline
* reading/listening via lib/offline. Shows idle → saving(%) → saved states and
* lets the user remove a saved copy. Renders nothing when the browser can't
* support offline storage.
*/
import { useEffect, useRef, useState } from "react";
import { Check, Download, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
isSaved as isSavedFn,
offlineSupported,
pageKey,
removeOffline,
saveOffline,
type OfflineDescriptor,
} from "@/lib/offline";
type State = "idle" | "saving" | "saved";
export function SaveOfflineButton({
descriptor,
}: {
descriptor: OfflineDescriptor;
}) {
const [supported, setSupported] = useState(false);
const [state, setState] = useState<State>("idle");
const [pct, setPct] = useState<number | null>(null);
const keyRef = useRef<string>("");
useEffect(() => {
if (!offlineSupported()) return;
setSupported(true);
keyRef.current = pageKey();
let alive = true;
isSavedFn(keyRef.current).then((saved) => {
if (alive && saved) setState("saved");
});
return () => {
alive = false;
};
}, []);
if (!supported) return null;
async function onClick() {
if (state === "saving") return;
if (state === "saved") {
// Toggle off — remove the offline copy.
try {
await removeOffline(keyRef.current);
setState("idle");
toast("Removed from offline", { duration: 1800 });
} catch {
toast.error("Couldn't remove the offline copy", { duration: 3000 });
}
return;
}
setState("saving");
setPct(descriptor.audioUrl ? 0 : null);
try {
const res = await saveOffline(descriptor, (f) =>
setPct(Math.round(f * 100)),
);
setState("saved");
if (res.audioError) {
toast(res.audioError, { duration: 4000 });
} else {
toast("Saved for offline", { duration: 1800 });
}
} catch {
setState("idle");
toast.error("Couldn't save this page offline", { duration: 3000 });
} finally {
setPct(null);
}
}
const label =
state === "saving"
? pct !== null
? `Saving… ${pct}%`
: "Saving…"
: state === "saved"
? "Saved offline"
: "Save offline";
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={
state === "saved" ? "Remove offline copy" : "Save this page for offline use"
}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors",
state === "saved"
? "text-foreground border-primary/40 hover:text-destructive hover:border-destructive/40 group"
: "border-border text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{state === "saving" ? (
<Loader2 className="size-3.5 animate-spin" />
) : state === "saved" ? (
<>
<Check className="size-3.5 text-primary group-hover:hidden" />
<Trash2 className="size-3.5 hidden group-hover:block" />
</>
) : (
<Download className="size-3.5" />
)}
{label}
</button>
);
}
+1
View File
@@ -33,6 +33,7 @@ const SECONDARY: NavItem[] = [
{ href: "/journals", label: "Journals" },
{ href: "/magazines", label: "Magazines" },
{ href: "/other-documents", label: "Other Documents" },
{ href: "/offline-content", label: "Offline library" },
{ href: "/about", label: "About" },
];
+6
View File
@@ -31,6 +31,7 @@ import { usePathname } from "next/navigation";
import {
AtSign,
Bookmark,
DownloadCloud,
FolderClosed,
Home,
LogIn,
@@ -95,6 +96,11 @@ const TOP_ITEMS: NavItem[] = [
label: "Mentions",
icon: <AtSign className="size-5" />,
},
{
href: "/offline-content",
label: "Offline",
icon: <DownloadCloud className="size-5" />,
},
];
export function SiteSidebar() {