feat: offline content
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /offline-content — the reader's device-local offline library.
|
||||
*
|
||||
* Lists everything saved via the header's "Save offline" button and lets them
|
||||
* open (offline-capable) or remove each item. Items link with a plain <a> (a
|
||||
* hard navigation) so the service worker can serve the cached HTML document
|
||||
* when there's no network — a soft client navigation would try the network and
|
||||
* fail. This page also doubles as the SW's offline navigation fallback.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
FileText,
|
||||
HardDriveDownload,
|
||||
Mail,
|
||||
Mic,
|
||||
Newspaper,
|
||||
Trash2,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import {
|
||||
listOffline,
|
||||
offlineSupported,
|
||||
removeOffline,
|
||||
usageBytes,
|
||||
type OfflineItem,
|
||||
type OfflineType,
|
||||
} from "@/lib/offline";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TYPE_META: Record<OfflineType, { label: string; icon: React.ReactNode }> = {
|
||||
verse: { label: "Verse", icon: <BookOpen className="size-4" /> },
|
||||
prose: { label: "Chapter", icon: <BookOpen className="size-4" /> },
|
||||
letter: { label: "Letter", icon: <Mail className="size-4" /> },
|
||||
magazine: { label: "Magazine", icon: <Newspaper className="size-4" /> },
|
||||
transcript: { label: "Transcript", icon: <Mic className="size-4" /> },
|
||||
journal: { label: "Journal", icon: <FileText className="size-4" /> },
|
||||
essay: { label: "Essay", icon: <FileText className="size-4" /> },
|
||||
document: { label: "Document", icon: <FileText className="size-4" /> },
|
||||
};
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (!n || n < 1024) return `${n || 0} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let v = n / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatDate(ms: number): string {
|
||||
try {
|
||||
return new Date(ms).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function OfflineContentPage() {
|
||||
const [items, setItems] = useState<OfflineItem[] | null>(null);
|
||||
const [usage, setUsage] = useState<number | null>(null);
|
||||
const [online, setOnline] = useState(true);
|
||||
const [supported, setSupported] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setItems(await listOffline());
|
||||
setUsage(await usageBytes());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!offlineSupported()) {
|
||||
setSupported(false);
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
setOnline(navigator.onLine);
|
||||
void refresh();
|
||||
const on = () => setOnline(true);
|
||||
const off = () => setOnline(false);
|
||||
window.addEventListener("online", on);
|
||||
window.addEventListener("offline", off);
|
||||
return () => {
|
||||
window.removeEventListener("online", on);
|
||||
window.removeEventListener("offline", off);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
async function remove(key: string) {
|
||||
await removeOffline(key);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader>
|
||||
<h1 className="text-2xl font-bold text-foreground">Offline library</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Pages and lectures you’ve saved to read or listen to without a
|
||||
connection. Stored on this device.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5",
|
||||
online ? "text-muted-foreground" : "text-amber-600",
|
||||
)}
|
||||
>
|
||||
{online ? (
|
||||
<Wifi className="size-3.5" />
|
||||
) : (
|
||||
<WifiOff className="size-3.5" />
|
||||
)}
|
||||
{online ? "Online" : "Offline"}
|
||||
</span>
|
||||
{usage !== null && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<HardDriveDownload className="size-3.5" />
|
||||
{formatBytes(usage)} used
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-8">
|
||||
{!supported ? (
|
||||
<p className="py-16 text-center text-sm text-muted-foreground">
|
||||
This browser doesn’t support offline saving.
|
||||
</p>
|
||||
) : items === null ? (
|
||||
<p className="py-16 text-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<HardDriveDownload className="mx-auto size-8 text-muted-foreground/50" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Nothing saved yet. Tap{" "}
|
||||
<span className="font-medium text-foreground">Save offline</span>{" "}
|
||||
on any chapter, letter, magazine, or transcript to keep it here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const meta = TYPE_META[item.type] ?? TYPE_META.document;
|
||||
return (
|
||||
<li
|
||||
key={item.key}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
{/* Hard navigation so the SW can serve the cached document offline. */}
|
||||
<a href={item.href} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{meta.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-foreground">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded bg-muted px-1.5 py-0.5">
|
||||
{meta.label}
|
||||
</span>
|
||||
{item.audioKey && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Mic className="size-3" /> audio
|
||||
</span>
|
||||
)}
|
||||
<span>{formatDate(item.savedAt)}</span>
|
||||
<span>· {formatBytes(item.bytes)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(item.key)}
|
||||
aria-label={`Remove ${item.title} from offline`}
|
||||
title="Remove from offline"
|
||||
className="shrink-0 rounded-md p-2 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,15 @@ export default function SettingsPage() {
|
||||
<NotificationMatrix />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Offline"
|
||||
description="Pages and lectures you've saved to read or listen to without a connection. Saved on this device — open the library to remove downloads and free up space."
|
||||
>
|
||||
<ul className="divide-y divide-border">
|
||||
<LegalLink href="/offline-content" label="Manage offline library" />
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="Authentication">
|
||||
{/* Suspense boundary required because LinkResult reads
|
||||
useSearchParams — Next.js bails out of SSR otherwise. */}
|
||||
|
||||
@@ -93,7 +93,15 @@ export default async function TranscriptionPage({
|
||||
isPartOf: "Lectures & Conversations of Śrīla Prabhupāda",
|
||||
})}
|
||||
/>
|
||||
<PageHeader sticky>
|
||||
<PageHeader
|
||||
sticky
|
||||
offline={{
|
||||
type: "transcript",
|
||||
title: t.name,
|
||||
subtitle: [t.date, t.place].filter(Boolean).join(" · ") || undefined,
|
||||
audioUrl: t.audioUrl ?? undefined,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AuthGateProvider } from "@/components/auth/auth-gate";
|
||||
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
|
||||
import { SharePostProvider } from "@/components/social/share-post-provider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ServiceWorkerRegistrar } from "@/components/pwa/sw-register";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -106,6 +107,7 @@ export default function RootLayout({
|
||||
route groups. */}
|
||||
<FloatingPlayer />
|
||||
<Toaster />
|
||||
<ServiceWorkerRegistrar />
|
||||
</SharePostProvider>
|
||||
</TooltipProvider>
|
||||
</AudioPlayerProvider>
|
||||
|
||||
@@ -18,6 +18,19 @@ import {
|
||||
syncLocalToRemote,
|
||||
type ProgressEntry,
|
||||
} from "@/lib/audio-progress";
|
||||
import {
|
||||
primeOfflineAudio,
|
||||
offlineAudioURL,
|
||||
releaseOfflineAudio,
|
||||
} from "@/lib/offline";
|
||||
|
||||
/**
|
||||
* Resolve the media source for a track: prefer a saved offline object URL
|
||||
* (works with no network + full seeking) and fall back to the remote URL.
|
||||
*/
|
||||
function resolveSrc(url: string): string {
|
||||
return offlineAudioURL(trackKey(url)) ?? url;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -242,6 +255,13 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
||||
currentIndexRef.current = currentIndex;
|
||||
}, [currentIndex]);
|
||||
|
||||
// Build object URLs for any audio saved offline so `resolveSrc` can prefer
|
||||
// them synchronously at play time (offline playback + full seeking).
|
||||
useEffect(() => {
|
||||
void primeOfflineAudio();
|
||||
return () => releaseOfflineAudio();
|
||||
}, []);
|
||||
|
||||
// --- Sync volume/muted to the <audio> element ---
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
@@ -334,7 +354,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
||||
setDuration(0);
|
||||
setIsLoading(true);
|
||||
if (audio) {
|
||||
audio.src = nextTrack.url;
|
||||
audio.src = resolveSrc(nextTrack.url);
|
||||
audio.load();
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] Auto-play next failed:", err);
|
||||
@@ -409,7 +429,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
||||
setDuration(0);
|
||||
setIsLoading(true);
|
||||
|
||||
audio.src = track.url;
|
||||
audio.src = resolveSrc(track.url);
|
||||
audio.load();
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] Play failed:", err);
|
||||
@@ -438,7 +458,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
||||
setDuration(0);
|
||||
setIsLoading(true);
|
||||
|
||||
audio.src = track.url;
|
||||
audio.src = resolveSrc(track.url);
|
||||
audio.load();
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] PlayPlaylist failed:", err);
|
||||
@@ -545,7 +565,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
||||
setDuration(0);
|
||||
setIsLoading(true);
|
||||
|
||||
audio.src = nextTrack.url;
|
||||
audio.src = resolveSrc(nextTrack.url);
|
||||
audio.load();
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] Next failed:", err);
|
||||
@@ -587,7 +607,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
||||
setDuration(0);
|
||||
setIsLoading(true);
|
||||
|
||||
audio.src = prevTrack.url;
|
||||
audio.src = resolveSrc(prevTrack.url);
|
||||
audio.load();
|
||||
audio.play().catch((err) => {
|
||||
console.error("[AudioPlayer] Previous failed:", err);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ServiceWorkerRegistrar — registers the app's service worker for everyone on
|
||||
* load (not just when a user opts into push). The same `/sw.js` now also
|
||||
* powers the Save-offline feature (caching page HTML + assets), so it must be
|
||||
* active regardless of push. The `?bff=` query matches the push registration
|
||||
* exactly (see lib/push.ts) so there's a single registration / scope, and push
|
||||
* subscription management keeps working.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { precacheOfflineLibrary } from "@/lib/offline";
|
||||
|
||||
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||
|
||||
export function ServiceWorkerRegistrar() {
|
||||
useEffect(() => {
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
const register = () => {
|
||||
navigator.serviceWorker
|
||||
.register(`/sw.js?bff=${encodeURIComponent(BFF_BASE)}`)
|
||||
.then(() => {
|
||||
// Precache the offline library so it works as the SW navigation
|
||||
// fallback even if the user never opens it online.
|
||||
void precacheOfflineLibrary();
|
||||
})
|
||||
.catch(() => {
|
||||
/* registration is best-effort; the app works online without it */
|
||||
});
|
||||
};
|
||||
// Defer past first paint so it never competes with hydration.
|
||||
if (document.readyState === "complete") {
|
||||
register();
|
||||
return;
|
||||
}
|
||||
window.addEventListener("load", register, { once: true });
|
||||
return () => window.removeEventListener("load", register);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Save-offline engine — lets a reader keep any content page (and, for
|
||||
* transcripts, its audio) available with no network.
|
||||
*
|
||||
* Two persistence layers, both device-local (Cache Storage + IndexedDB are
|
||||
* per-browser, so saved bytes can't roam across devices):
|
||||
* - **Page HTML + `/_next` assets** → Cache Storage, served back by the
|
||||
* service worker (`public/sw.js`) on navigation when offline.
|
||||
* - **Transcript audio** → a Blob in IndexedDB, played through a pre-created
|
||||
* object URL (full native seeking; see `primeOfflineAudio`).
|
||||
*
|
||||
* An `items` index (IndexedDB) records what's saved so `/offline-content` can
|
||||
* list it and the header button can show saved state.
|
||||
*/
|
||||
|
||||
import { trackKey } from "@/lib/audio-progress";
|
||||
|
||||
// Must match CACHE_VERSION / cache names in public/sw.js.
|
||||
const CACHE_VERSION = "v1";
|
||||
const PAGES_CACHE = `offline-pages-${CACHE_VERSION}`;
|
||||
const ASSETS_CACHE = `offline-assets-${CACHE_VERSION}`;
|
||||
|
||||
const IDB_NAME = "spsays-offline";
|
||||
const IDB_VERSION = 1;
|
||||
const ITEMS_STORE = "items";
|
||||
const AUDIO_STORE = "audio";
|
||||
|
||||
export type OfflineType =
|
||||
| "verse"
|
||||
| "prose"
|
||||
| "letter"
|
||||
| "magazine"
|
||||
| "transcript"
|
||||
| "journal"
|
||||
| "essay"
|
||||
| "document";
|
||||
|
||||
/** What a content page hands the Save-offline button. */
|
||||
export type OfflineDescriptor = {
|
||||
type: OfflineType;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Present for transcripts — the audio is downloaded too. */
|
||||
audioUrl?: string;
|
||||
};
|
||||
|
||||
/** A saved item as stored in the `items` index. */
|
||||
export type OfflineItem = {
|
||||
key: string; // location.pathname — stable id + how the UI checks saved state
|
||||
href: string; // absolute page URL that was cached
|
||||
type: OfflineType;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
audioKey?: string; // trackKey(audioUrl) when audio was saved
|
||||
savedAt: number;
|
||||
bytes: number; // approximate footprint (html + assets + audio)
|
||||
};
|
||||
|
||||
type AudioRecord = { key: string; blob: Blob; type: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IndexedDB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(IDB_NAME, IDB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(ITEMS_STORE)) {
|
||||
db.createObjectStore(ITEMS_STORE, { keyPath: "key" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(AUDIO_STORE)) {
|
||||
db.createObjectStore(AUDIO_STORE, { keyPath: "key" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function tx<T>(
|
||||
store: string,
|
||||
mode: IDBTransactionMode,
|
||||
run: (s: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return openDB().then(
|
||||
(db) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const t = db.transaction(store, mode);
|
||||
const req = run(t.objectStore(store));
|
||||
t.oncomplete = () => {
|
||||
db.close();
|
||||
resolve(req.result as T);
|
||||
};
|
||||
t.onerror = () => reject(t.error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Availability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function offlineSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
"caches" in window &&
|
||||
"indexedDB" in window &&
|
||||
"serviceWorker" in navigator
|
||||
);
|
||||
}
|
||||
|
||||
/** Item key for the page currently being viewed. */
|
||||
export function pageKey(): string {
|
||||
return window.location.pathname;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save / remove
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Stream a URL into a Blob, reporting 0..1 progress when Content-Length is
|
||||
* known. Throws on network / CORS failure (caller surfaces it). */
|
||||
async function downloadBlob(
|
||||
url: string,
|
||||
onProgress?: (fraction: number) => void,
|
||||
): Promise<Blob> {
|
||||
const res = await fetch(url); // CORS mode — needed to read the bytes
|
||||
if (!res.ok) throw new Error(`audio ${res.status}`);
|
||||
const type = res.headers.get("content-type") || "audio/mpeg";
|
||||
const total = Number(res.headers.get("content-length")) || 0;
|
||||
if (!res.body || !total) return await res.blob();
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
onProgress?.(received / total);
|
||||
}
|
||||
return new Blob(chunks as BlobPart[], { type });
|
||||
}
|
||||
|
||||
/** Same-origin `/_next` + static asset URLs referenced by a page's HTML. */
|
||||
function extractAssetUrls(html: string, baseHref: string): string[] {
|
||||
const urls = new Set<string>();
|
||||
const push = (raw: string | null) => {
|
||||
if (!raw) return;
|
||||
try {
|
||||
const u = new URL(raw, baseHref);
|
||||
if (u.origin !== window.location.origin) return;
|
||||
if (
|
||||
u.pathname.startsWith("/_next/") ||
|
||||
/\.(?:js|css|woff2?|ttf|otf|png|jpe?g|webp|avif|svg|ico|gif)$/.test(
|
||||
u.pathname,
|
||||
)
|
||||
) {
|
||||
urls.add(u.href);
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
};
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
doc.querySelectorAll("script[src]").forEach((el) => push(el.getAttribute("src")));
|
||||
doc
|
||||
.querySelectorAll("link[href]")
|
||||
.forEach((el) => {
|
||||
const rel = (el.getAttribute("rel") || "").toLowerCase();
|
||||
if (/stylesheet|preload|modulepreload|prefetch/.test(rel)) {
|
||||
push(el.getAttribute("href"));
|
||||
}
|
||||
});
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
/** Cache a page's HTML document + its referenced /_next & static assets so the
|
||||
* service worker can serve it (and boot the app shell) offline. Returns rough
|
||||
* sizes for the saved-item footprint. */
|
||||
async function cachePageAndAssets(
|
||||
pageUrl: string,
|
||||
): Promise<{ htmlLength: number; assetCount: number }> {
|
||||
// Read once, cache a fresh Response, reuse the text for asset discovery.
|
||||
const pageRes = await fetch(pageUrl, { credentials: "same-origin" });
|
||||
if (!pageRes.ok) throw new Error(`Couldn't fetch the page (${pageRes.status}).`);
|
||||
const html = await pageRes.text();
|
||||
const contentType =
|
||||
pageRes.headers.get("content-type") || "text/html; charset=utf-8";
|
||||
const pagesCache = await caches.open(PAGES_CACHE);
|
||||
await pagesCache.put(
|
||||
pageUrl,
|
||||
new Response(html, { headers: { "Content-Type": contentType } }),
|
||||
);
|
||||
|
||||
const assetUrls = extractAssetUrls(html, pageUrl);
|
||||
const assetsCache = await caches.open(ASSETS_CACHE);
|
||||
await Promise.all(
|
||||
assetUrls.map(async (u) => {
|
||||
try {
|
||||
if (await assetsCache.match(u)) return;
|
||||
const r = await fetch(u);
|
||||
if (r.ok) await assetsCache.put(u, r.clone());
|
||||
} catch {
|
||||
/* a missing asset shouldn't fail the whole save */
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { htmlLength: html.length, assetCount: assetUrls.length };
|
||||
}
|
||||
|
||||
/** Precache the /offline-content library page so it works as the SW's offline
|
||||
* navigation fallback even if the user never opened it online. Best-effort. */
|
||||
export async function precacheOfflineLibrary(): Promise<void> {
|
||||
if (!offlineSupported()) return;
|
||||
try {
|
||||
await cachePageAndAssets(
|
||||
new URL("/offline-content", window.location.origin).href,
|
||||
);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
export type SaveResult = {
|
||||
item: OfflineItem;
|
||||
audioSaved: boolean;
|
||||
audioError?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Save the page currently being viewed for offline use. Reads window.location
|
||||
* for the URL/key, so callers pass only the descriptive metadata.
|
||||
*/
|
||||
export async function saveOffline(
|
||||
descriptor: OfflineDescriptor,
|
||||
onAudioProgress?: (fraction: number) => void,
|
||||
): Promise<SaveResult> {
|
||||
if (!offlineSupported()) throw new Error("Offline saving isn't supported here.");
|
||||
|
||||
const href = window.location.href;
|
||||
const key = window.location.pathname;
|
||||
|
||||
// Best-effort: ask the browser not to evict our storage.
|
||||
try {
|
||||
await navigator.storage?.persist?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// 1 + 2. Page HTML + its /_next & static assets.
|
||||
const { htmlLength, assetCount } = await cachePageAndAssets(href);
|
||||
|
||||
// 3. Audio (transcripts).
|
||||
let audioKey: string | undefined;
|
||||
let audioBytes = 0;
|
||||
let audioSaved = false;
|
||||
let audioError: string | undefined;
|
||||
if (descriptor.audioUrl) {
|
||||
try {
|
||||
const blob = await downloadBlob(descriptor.audioUrl, onAudioProgress);
|
||||
audioKey = trackKey(descriptor.audioUrl);
|
||||
await tx(AUDIO_STORE, "readwrite", (s) =>
|
||||
s.put({ key: audioKey, blob, type: blob.type } as AudioRecord),
|
||||
);
|
||||
audioBytes = blob.size;
|
||||
audioSaved = true;
|
||||
// Make it playable this session without a reload.
|
||||
cacheAudioObjectURL(audioKey, blob);
|
||||
} catch {
|
||||
// Text is still saved; the audio host likely lacks CORS headers.
|
||||
audioError =
|
||||
"Audio couldn't be downloaded (the page text is saved). Try again on a better connection.";
|
||||
}
|
||||
}
|
||||
|
||||
const bytes =
|
||||
htmlLength + assetCount * 40_000 /* rough */ + audioBytes;
|
||||
const item: OfflineItem = {
|
||||
key,
|
||||
href,
|
||||
type: descriptor.type,
|
||||
// Fall back to the live document title when the header derived the
|
||||
// descriptor without an explicit title.
|
||||
title: descriptor.title || document.title || href,
|
||||
subtitle: descriptor.subtitle,
|
||||
audioKey,
|
||||
savedAt: Date.now(),
|
||||
bytes,
|
||||
};
|
||||
await tx(ITEMS_STORE, "readwrite", (s) => s.put(item));
|
||||
|
||||
return { item, audioSaved, audioError };
|
||||
}
|
||||
|
||||
/** Remove a saved item. Shared `/_next` assets are intentionally left in the
|
||||
* assets cache (other saved pages may need them; they're purged on version
|
||||
* bump). */
|
||||
export async function removeOffline(key: string): Promise<void> {
|
||||
const item = await tx<OfflineItem | undefined>(ITEMS_STORE, "readonly", (s) =>
|
||||
s.get(key),
|
||||
);
|
||||
if (item) {
|
||||
try {
|
||||
const pages = await caches.open(PAGES_CACHE);
|
||||
await pages.delete(item.href);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (item.audioKey) {
|
||||
await tx(AUDIO_STORE, "readwrite", (s) => s.delete(item.audioKey!));
|
||||
revokeAudioObjectURL(item.audioKey);
|
||||
}
|
||||
}
|
||||
await tx(ITEMS_STORE, "readwrite", (s) => s.delete(key));
|
||||
}
|
||||
|
||||
export async function listOffline(): Promise<OfflineItem[]> {
|
||||
if (!offlineSupported()) return [];
|
||||
try {
|
||||
const all = await tx<OfflineItem[]>(ITEMS_STORE, "readonly", (s) => s.getAll());
|
||||
return (all ?? []).sort((a, b) => b.savedAt - a.savedAt);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSaved(key: string): Promise<boolean> {
|
||||
if (!offlineSupported()) return false;
|
||||
try {
|
||||
const item = await tx<OfflineItem | undefined>(ITEMS_STORE, "readonly", (s) =>
|
||||
s.get(key),
|
||||
);
|
||||
return !!item;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Total bytes our storage occupies, if the browser reports it. */
|
||||
export async function usageBytes(): Promise<number | null> {
|
||||
try {
|
||||
const est = await navigator.storage?.estimate?.();
|
||||
return est?.usage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline audio object-URL cache (synchronous lookup for the player)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const audioObjectURLs = new Map<string, string>();
|
||||
|
||||
function cacheAudioObjectURL(key: string, blob: Blob) {
|
||||
const existing = audioObjectURLs.get(key);
|
||||
if (existing) URL.revokeObjectURL(existing);
|
||||
audioObjectURLs.set(key, URL.createObjectURL(blob));
|
||||
}
|
||||
|
||||
function revokeAudioObjectURL(key: string) {
|
||||
const url = audioObjectURLs.get(key);
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
audioObjectURLs.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build object URLs for every saved audio blob up front, so the player can look
|
||||
* one up **synchronously** at play time (an async blob fetch inside a click
|
||||
* handler would break the iOS autoplay user-gesture requirement).
|
||||
*/
|
||||
export async function primeOfflineAudio(): Promise<void> {
|
||||
if (!offlineSupported()) return;
|
||||
try {
|
||||
const records = await tx<AudioRecord[]>(AUDIO_STORE, "readonly", (s) =>
|
||||
s.getAll(),
|
||||
);
|
||||
for (const rec of records ?? []) {
|
||||
if (!audioObjectURLs.has(rec.key)) cacheAudioObjectURL(rec.key, rec.blob);
|
||||
}
|
||||
} catch {
|
||||
/* no offline audio */
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous object-URL lookup for a track key, or undefined if not saved. */
|
||||
export function offlineAudioURL(key: string): string | undefined {
|
||||
return audioObjectURLs.get(key);
|
||||
}
|
||||
|
||||
/** Revoke all object URLs (call on player unmount). */
|
||||
export function releaseOfflineAudio(): void {
|
||||
for (const url of audioObjectURLs.values()) URL.revokeObjectURL(url);
|
||||
audioObjectURLs.clear();
|
||||
}
|
||||
+8
-5
@@ -110,11 +110,14 @@ export async function enablePush(): Promise<void> {
|
||||
throw new Error("Notification permission was not granted.");
|
||||
}
|
||||
|
||||
// The BFF origin rides along as a query param so the service worker can
|
||||
// re-register rotated subscriptions (pushsubscriptionchange) on its own.
|
||||
const reg = await navigator.serviceWorker.register(
|
||||
`/sw.js?bff=${encodeURIComponent(BFF_BASE)}`,
|
||||
);
|
||||
// The service worker is registered app-wide by ServiceWorkerRegistrar (with
|
||||
// the same `?bff=` query, so there's a single registration/scope). Reuse it;
|
||||
// only register here as a fallback if it somehow isn't present yet.
|
||||
const reg =
|
||||
(await navigator.serviceWorker.getRegistration()) ??
|
||||
(await navigator.serviceWorker.register(
|
||||
`/sw.js?bff=${encodeURIComponent(BFF_BASE)}`,
|
||||
));
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
|
||||
+80
-1
@@ -6,11 +6,90 @@
|
||||
* { title, body, url, icon, tag, require_interaction }
|
||||
*/
|
||||
|
||||
// --- Offline caching (Save-offline feature) ---------------------------------
|
||||
// Bump CACHE_VERSION to invalidate old asset/page caches on the next activate.
|
||||
const CACHE_VERSION = "v1";
|
||||
const PAGES_CACHE = `offline-pages-${CACHE_VERSION}`; // saved page HTML documents
|
||||
const ASSETS_CACHE = `offline-assets-${CACHE_VERSION}`; // /_next/* + static files
|
||||
const OFFLINE_CACHES = [PAGES_CACHE, ASSETS_CACHE];
|
||||
// The library page doubles as the navigation fallback when offline.
|
||||
const OFFLINE_FALLBACK = "/offline-content";
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", (event) =>
|
||||
event.waitUntil(self.clients.claim()),
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// Drop stale offline caches from previous CACHE_VERSIONs.
|
||||
const names = await caches.keys();
|
||||
await Promise.all(
|
||||
names
|
||||
.filter(
|
||||
(n) => n.startsWith("offline-") && !OFFLINE_CACHES.includes(n),
|
||||
)
|
||||
.map((n) => caches.delete(n)),
|
||||
);
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
),
|
||||
);
|
||||
|
||||
// Treat content-hashed Next assets and static files as immutable → cache-first.
|
||||
function isCacheableAsset(url) {
|
||||
if (url.origin !== self.location.origin) return false;
|
||||
if (url.pathname.startsWith("/_next/")) return true;
|
||||
return /\.(?:js|css|woff2?|ttf|otf|png|jpe?g|webp|avif|svg|ico|gif)$/.test(
|
||||
url.pathname,
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return; // never touch POSTs (push subscribe etc.)
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// 1. Page navigations: network-first, fall back to a saved copy, then to the
|
||||
// offline library. This is what makes a "saved" page open with no network.
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
return await fetch(req);
|
||||
} catch {
|
||||
const pages = await caches.open(PAGES_CACHE);
|
||||
const saved =
|
||||
(await pages.match(req.url)) || (await pages.match(url.pathname));
|
||||
if (saved) return saved;
|
||||
const fallback = await pages.match(OFFLINE_FALLBACK);
|
||||
if (fallback) return fallback;
|
||||
return new Response(
|
||||
"<!doctype html><meta charset=utf-8><title>Offline</title><body style=\"font-family:system-ui;padding:2rem\"><h1>You're offline</h1><p>This page hasn't been saved for offline use.</p>",
|
||||
{ headers: { "Content-Type": "text/html; charset=utf-8" }, status: 503 },
|
||||
);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Immutable same-origin assets: cache-first so the app shell boots offline.
|
||||
if (isCacheableAsset(url)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(ASSETS_CACHE);
|
||||
const hit = await cache.match(req);
|
||||
if (hit) return hit;
|
||||
const res = await fetch(req);
|
||||
if (res && res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
})(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Everything else (BFF data, RSC, /api): straight to network.
|
||||
});
|
||||
|
||||
// The page registers us as /sw.js?bff=<origin> so subscription management
|
||||
// works without a client tab open (the auth cookie rides on
|
||||
// credentials:"include"). Same-origin deployments fall back to our origin.
|
||||
|
||||
Reference in New Issue
Block a user