summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-09 23:13:08 -0800
committerFuwn <[email protected]>2026-02-09 23:13:08 -0800
commit2cb58bde47a2475aa85184e34a1432a997312186 (patch)
treeed5a7e4ef4d96da03028294842a5e0e03360242d /apps/web
parentfix: add spacing between collapsible appearance settings sections (diff)
downloadasa.news-2cb58bde47a2475aa85184e34a1432a997312186.tar.xz
asa.news-2cb58bde47a2475aa85184e34a1432a997312186.zip
feat: offline support tier 1 — IndexedDB query persistence and offline banner
Persist React Query cache to IndexedDB via idb-keyval so timeline, entry details, subscriptions, and other read data survive page reloads and brief offline periods. Add network status banner in reader layout.
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/app/providers.tsx32
-rw-r--r--apps/web/app/reader/_components/offline-banner.tsx32
-rw-r--r--apps/web/app/reader/_components/reader-layout-shell.tsx6
-rw-r--r--apps/web/lib/indexed-database-persister.ts18
-rw-r--r--apps/web/lib/query-client.ts3
-rw-r--r--apps/web/package.json2
6 files changed, 89 insertions, 4 deletions
diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx
index efe6775..8551748 100644
--- a/apps/web/app/providers.tsx
+++ b/apps/web/app/providers.tsx
@@ -1,15 +1,41 @@
"use client"
import { useState } from "react"
-import { QueryClientProvider } from "@tanstack/react-query"
+import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"
import { Toaster } from "sonner"
import { createQueryClient } from "@/lib/query-client"
+import { createIndexedDatabasePersister } from "@/lib/indexed-database-persister"
+
+const PERSISTABLE_QUERY_KEY_PREFIXES = [
+ "timeline",
+ "saved-entries",
+ "subscriptions",
+ "entry-detail",
+ "user-profile",
+ "unread-counts",
+ "highlights",
+ "custom-feeds",
+ "custom-feed-timeline",
+]
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(createQueryClient)
+ const [persister] = useState(createIndexedDatabasePersister)
return (
- <QueryClientProvider client={queryClient}>
+ <PersistQueryClientProvider
+ client={queryClient}
+ persistOptions={{
+ persister,
+ dehydrateOptions: {
+ shouldDehydrateQuery: (query) => {
+ if (query.state.status !== "success") return false
+ const prefix = query.queryKey[0]
+ return typeof prefix === "string" && PERSISTABLE_QUERY_KEY_PREFIXES.includes(prefix)
+ },
+ },
+ }}
+ >
{children}
<Toaster
position="bottom-right"
@@ -25,6 +51,6 @@ export function Providers({ children }: { children: React.ReactNode }) {
},
}}
/>
- </QueryClientProvider>
+ </PersistQueryClientProvider>
)
}
diff --git a/apps/web/app/reader/_components/offline-banner.tsx b/apps/web/app/reader/_components/offline-banner.tsx
new file mode 100644
index 0000000..81ebf8f
--- /dev/null
+++ b/apps/web/app/reader/_components/offline-banner.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import { useSyncExternalStore } from "react"
+
+function subscribe(callback: () => void) {
+ window.addEventListener("online", callback)
+ window.addEventListener("offline", callback)
+ return () => {
+ window.removeEventListener("online", callback)
+ window.removeEventListener("offline", callback)
+ }
+}
+
+function getSnapshot() {
+ return navigator.onLine
+}
+
+function getServerSnapshot() {
+ return true
+}
+
+export function OfflineBanner() {
+ const isOnline = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
+
+ if (isOnline) return null
+
+ return (
+ <div className="border-b border-border bg-background-tertiary px-3 py-1.5 text-center text-text-dim">
+ you are offline — showing cached content
+ </div>
+ )
+}
diff --git a/apps/web/app/reader/_components/reader-layout-shell.tsx b/apps/web/app/reader/_components/reader-layout-shell.tsx
index 9d2eb24..fe158b5 100644
--- a/apps/web/app/reader/_components/reader-layout-shell.tsx
+++ b/apps/web/app/reader/_components/reader-layout-shell.tsx
@@ -12,6 +12,7 @@ import { AddFeedDialog } from "./add-feed-dialog"
import { SearchOverlay } from "./search-overlay"
import { KeyboardShortcutsDialog } from "./keyboard-shortcuts-dialog"
import { MfaChallenge } from "./mfa-challenge"
+import { OfflineBanner } from "./offline-banner"
import { useKeyboardNavigation } from "@/lib/hooks/use-keyboard-navigation"
import { createSupabaseBrowserClient } from "@/lib/supabase/client"
import { useSubscriptions } from "@/lib/queries/use-subscriptions"
@@ -284,7 +285,9 @@ export function ReaderLayoutShell({
}
return (
- <div className="flex h-screen">
+ <div className="flex h-screen flex-col">
+ <OfflineBanner />
+ <div className="flex min-h-0 flex-1">
{isMobile ? (
<>
<div
@@ -405,6 +408,7 @@ export function ReaderLayoutShell({
<SearchOverlay onClose={() => setSearchOpen(false)} />
)}
<KeyboardShortcutsDialog />
+ </div>
</div>
)
}
diff --git a/apps/web/lib/indexed-database-persister.ts b/apps/web/lib/indexed-database-persister.ts
new file mode 100644
index 0000000..216f409
--- /dev/null
+++ b/apps/web/lib/indexed-database-persister.ts
@@ -0,0 +1,18 @@
+import { get, set, del } from "idb-keyval"
+import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"
+
+const INDEXED_DATABASE_KEY = "asa-news-react-query-cache"
+
+export function createIndexedDatabasePersister(): Persister {
+ return {
+ persistClient: async (client: PersistedClient) => {
+ await set(INDEXED_DATABASE_KEY, client)
+ },
+ restoreClient: async () => {
+ return await get<PersistedClient>(INDEXED_DATABASE_KEY)
+ },
+ removeClient: async () => {
+ await del(INDEXED_DATABASE_KEY)
+ },
+ }
+}
diff --git a/apps/web/lib/query-client.ts b/apps/web/lib/query-client.ts
index 82be2df..c64e4c7 100644
--- a/apps/web/lib/query-client.ts
+++ b/apps/web/lib/query-client.ts
@@ -1,10 +1,13 @@
import { QueryClient } from "@tanstack/react-query"
+const TWENTY_FOUR_HOURS_IN_MILLISECONDS = 1000 * 60 * 60 * 24
+
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
+ gcTime: TWENTY_FOUR_HOURS_IN_MILLISECONDS,
refetchOnWindowFocus: false,
},
},
diff --git a/apps/web/package.json b/apps/web/package.json
index d7a5f42..b548ff3 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,11 +14,13 @@
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.95.2",
"@tanstack/react-query": "^5.90.20",
+ "@tanstack/react-query-persist-client": "^5.90.22",
"@tanstack/react-virtual": "^3.13.18",
"botid": "^1.5.10",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
+ "idb-keyval": "^6.2.2",
"next": "16.1.6",
"next-themes": "^0.4.6",
"react": "19.2.3",