From 380c23e4696e13d852fce34bca154af44ab2d09d Mon Sep 17 00:00:00 2001 From: gosangam Date: Thu, 2 Jul 2026 21:32:22 +0530 Subject: [PATCH] feat: offline content --- app/(app)/offline-content/page.tsx | 203 +++++++++++ app/(app)/settings/page.tsx | 9 + app/(app)/transcriptions/[slug]/page.tsx | 10 +- app/layout.tsx | 2 + components/audio-player/audio-context.tsx | 30 +- components/layout/page-header.tsx | 59 +++- components/layout/save-offline-button.tsx | 124 +++++++ components/layout/site-mobile-nav.tsx | 1 + components/layout/site-sidebar.tsx | 6 + components/pwa/sw-register.tsx | 44 +++ lib/offline.ts | 401 ++++++++++++++++++++++ lib/push.ts | 13 +- public/sw.js | 81 ++++- 13 files changed, 969 insertions(+), 14 deletions(-) create mode 100644 app/(app)/offline-content/page.tsx create mode 100644 components/layout/save-offline-button.tsx create mode 100644 components/pwa/sw-register.tsx create mode 100644 lib/offline.ts diff --git a/app/(app)/offline-content/page.tsx b/app/(app)/offline-content/page.tsx new file mode 100644 index 00000000..9285d505 --- /dev/null +++ b/app/(app)/offline-content/page.tsx @@ -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 + * 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 = { + verse: { label: "Verse", icon: }, + prose: { label: "Chapter", icon: }, + letter: { label: "Letter", icon: }, + magazine: { label: "Magazine", icon: }, + transcript: { label: "Transcript", icon: }, + journal: { label: "Journal", icon: }, + essay: { label: "Essay", icon: }, + document: { label: "Document", icon: }, +}; + +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(null); + const [usage, setUsage] = useState(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 ( + <> + +

Offline library

+

+ Pages and lectures you’ve saved to read or listen to without a + connection. Stored on this device. +

+
+ + {online ? ( + + ) : ( + + )} + {online ? "Online" : "Offline"} + + {usage !== null && ( + + + {formatBytes(usage)} used + + )} +
+
+ +
+ {!supported ? ( +

+ This browser doesn’t support offline saving. +

+ ) : items === null ? ( +

+ Loading… +

+ ) : items.length === 0 ? ( +
+ +

+ Nothing saved yet. Tap{" "} + Save offline{" "} + on any chapter, letter, magazine, or transcript to keep it here. +

+
+ ) : ( +
+ )} +
+ + ); +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 04e4db8a..30d0f2c6 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -66,6 +66,15 @@ export default function SettingsPage() { +
+
    + +
+
+
{/* Suspense boundary required because LinkResult reads useSearchParams — Next.js bails out of SSR otherwise. */} diff --git a/app/(app)/transcriptions/[slug]/page.tsx b/app/(app)/transcriptions/[slug]/page.tsx index a1ec4913..c7641def 100644 --- a/app/(app)/transcriptions/[slug]/page.tsx +++ b/app/(app)/transcriptions/[slug]/page.tsx @@ -93,7 +93,15 @@ export default async function TranscriptionPage({ isPartOf: "Lectures & Conversations of Śrīla Prabhupāda", })} /> - + + diff --git a/components/audio-player/audio-context.tsx b/components/audio-player/audio-context.tsx index 0b5acb42..5c089764 100644 --- a/components/audio-player/audio-context.tsx +++ b/components/audio-player/audio-context.tsx @@ -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