diff --git a/app/(app)/offline-content/page.tsx b/app/(app)/downloads/page.tsx similarity index 85% rename from app/(app)/offline-content/page.tsx rename to app/(app)/downloads/page.tsx index 7bf1165c..802e8738 100644 --- a/app/(app)/offline-content/page.tsx +++ b/app/(app)/downloads/page.tsx @@ -1,7 +1,7 @@ "use client"; /** - * /offline-content — the reader's device-local offline library. + * /downloads — 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 @@ -10,7 +10,7 @@ * fail. This page also doubles as the SW's offline navigation fallback. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { BookOpen, FileText, @@ -67,33 +67,45 @@ function formatDate(ms: number): string { } } -export default function OfflineContentPage() { +// External-store readers for the two client-only, non-React values. Using +// useSyncExternalStore (instead of setState-in-an-effect) is SSR-safe and +// avoids the "setState synchronously within an effect" cascade warning. +function subscribeOnline(cb: () => void) { + window.addEventListener("online", cb); + window.addEventListener("offline", cb); + return () => { + window.removeEventListener("online", cb); + window.removeEventListener("offline", cb); + }; +} +const noopSubscribe = () => () => {}; + +export default function DownloadsPage() { const [items, setItems] = useState(null); const [usage, setUsage] = useState(null); - const [online, setOnline] = useState(true); - const [supported, setSupported] = useState(true); + + // Client-only reads via external stores (SSR snapshot → server-safe default). + const online = useSyncExternalStore( + subscribeOnline, + () => navigator.onLine, + () => true, + ); + const supported = useSyncExternalStore( + noopSubscribe, + () => offlineSupported(), + () => false, + ); const refresh = useCallback(async () => { setItems(await listOffline()); setUsage(await usageBytes()); }, []); + // Load the library once mounted. The setState calls happen after an await + // (asynchronously), not synchronously in the effect body. useEffect(() => { - if (!offlineSupported()) { - setSupported(false); - setItems([]); - return; - } - setOnline(navigator.onLine); + if (!offlineSupported()) return; 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) { diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 30d0f2c6..7bc27ba5 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -71,7 +71,7 @@ export default function SettingsPage() { 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." >
    - +
diff --git a/components/layout/site-mobile-nav.tsx b/components/layout/site-mobile-nav.tsx index 52c5a2fc..bad68373 100644 --- a/components/layout/site-mobile-nav.tsx +++ b/components/layout/site-mobile-nav.tsx @@ -33,7 +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: "/downloads", label: "Downloads" }, { href: "/about", label: "About" }, ]; diff --git a/components/layout/site-sidebar.tsx b/components/layout/site-sidebar.tsx index 87a0f927..cff63f15 100644 --- a/components/layout/site-sidebar.tsx +++ b/components/layout/site-sidebar.tsx @@ -97,8 +97,8 @@ const TOP_ITEMS: NavItem[] = [ icon: , }, { - href: "/offline-content", - label: "Offline", + href: "/downloads", + label: "Downloads", icon: , }, ]; diff --git a/lib/offline.ts b/lib/offline.ts index 0e93b4ee..b7a6c029 100644 --- a/lib/offline.ts +++ b/lib/offline.ts @@ -9,7 +9,7 @@ * - **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 + * An `items` index (IndexedDB) records what's saved so `/downloads` can * list it and the header button can show saved state. */ @@ -212,13 +212,13 @@ async function cachePageAndAssets( return { htmlLength: html.length, assetCount: assetUrls.length }; } -/** Precache the /offline-content library page so it works as the SW's offline +/** Precache the /downloads 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 { if (!offlineSupported()) return; try { await cachePageAndAssets( - new URL("/offline-content", window.location.origin).href, + new URL("/downloads", window.location.origin).href, ); } catch { /* best-effort */ diff --git a/public/sw.js b/public/sw.js index 37cc7da2..e7a5b7dc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -13,7 +13,7 @@ const PAGES_CACHE = `offline-pages-${CACHE_VERSION}`; // saved page HTML documen 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"; +const OFFLINE_FALLBACK = "/downloads"; self.addEventListener("install", () => self.skipWaiting()); self.addEventListener("activate", (event) =>