diff options
| author | Fuwn <[email protected]> | 2026-02-09 23:13:08 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-09 23:13:08 -0800 |
| commit | 2cb58bde47a2475aa85184e34a1432a997312186 (patch) | |
| tree | ed5a7e4ef4d96da03028294842a5e0e03360242d /apps/web | |
| parent | fix: add spacing between collapsible appearance settings sections (diff) | |
| download | asa.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.tsx | 32 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/offline-banner.tsx | 32 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/reader-layout-shell.tsx | 6 | ||||
| -rw-r--r-- | apps/web/lib/indexed-database-persister.ts | 18 | ||||
| -rw-r--r-- | apps/web/lib/query-client.ts | 3 | ||||
| -rw-r--r-- | apps/web/package.json | 2 |
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", |