aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-02 21:22:41 -0800
committerAdam Soltys <[email protected]>2026-03-02 21:22:41 -0800
commit7c4d393feaf9c594fa6bdae274abc0edd45c3373 (patch)
tree74ea34dbbc9b7399b910ae92af9208f2c787ecde /src
parent90f29dce3d675f850e48ede2edb187e6c53b84da (diff)
downloadcoinos-ui-7c4d393feaf9c594fa6bdae274abc0edd45c3373.tar.gz
coinos-ui-7c4d393feaf9c594fa6bdae274abc0edd45c3373.zip
offline mode
Diffstat (limited to 'src')
-rw-r--r--src/components/OfflineBanner.svelte10
-rw-r--r--src/lib/store.ts5
-rw-r--r--src/routes/(app)/+layout.svelte13
-rw-r--r--src/routes/(app)/[username]/(profile)/+page.svelte8
-rw-r--r--src/routes/(app)/payments/[...page]/+page.svelte5
-rw-r--r--src/service-worker.js102
6 files changed, 136 insertions, 7 deletions
diff --git a/src/components/OfflineBanner.svelte b/src/components/OfflineBanner.svelte
new file mode 100644
index 00000000..5b59513d
--- /dev/null
+++ b/src/components/OfflineBanner.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ import { offline } from "$lib/store";
+</script>
+
+{#if $offline}
+ <div class="alert alert-warning sticky top-0 z-50 rounded-none justify-center gap-2">
+ <iconify-icon icon="ph:wifi-slash-bold" width="20"></iconify-icon>
+ You're offline — showing cached data
+ </div>
+{/if}
diff --git a/src/lib/store.ts b/src/lib/store.ts
index 435df4b2..13aa8bd8 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -101,3 +101,8 @@ export const paymentSignal = writable(0);
export const lastPayment = writable<{ iid: string; amount: number; confirmed: boolean } | null>(
null,
);
+export const cachedUser = persistLocal("cachedUser");
+export const cachedAccounts = persistLocal("cachedAccounts", []);
+export const cachedPayments = persistLocal("cachedPayments", []);
+export const cachedRates = persistLocal("cachedRates");
+export const offline = writable(false);
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index 40f28444..748dfdba 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -16,11 +16,14 @@
theme as themeStore,
fiat,
rate as rateStore,
+ cachedUser,
+ offline,
} from "$lib/store";
import { page } from "$app/stores";
import { browser } from "$app/environment";
import LoadingSplash from "$comp/LoadingSplash.svelte";
import AppHeader from "$comp/AppHeader.svelte";
+ import OfflineBanner from "$comp/OfflineBanner.svelte";
import Nostr from "$comp/Nostr.svelte";
import Password from "$comp/Password.svelte";
import { s, f, toFiat, success, getCookie, warning, post } from "$lib/utils";
@@ -41,6 +44,9 @@
$effect(() => {
theme = $themeStore;
});
+ $effect(() => {
+ if (user) $cachedUser = user;
+ });
afterNavigate(() => {
document.cookie = `pathname=${$page.url.pathname}; path=/; max-age=86400`;
@@ -172,6 +178,12 @@
onMount(async () => {
if (browser) {
+ $offline = !navigator.onLine;
+ const goOnline = () => ($offline = false);
+ const goOffline = () => ($offline = true);
+ window.addEventListener("online", goOnline);
+ window.addEventListener("offline", goOffline);
+
checkSocket();
$pin = getCookie("pin");
@@ -274,6 +286,7 @@
<SvelteToast options={{ reversed: true, intro: { y: 192 } }} />
<main class="pb-4 min-h-dvh" data-theme={theme}>
+ <OfflineBanner />
<AppHeader {user} {subject} />
{#if !$loading}
{@render children?.()}
diff --git a/src/routes/(app)/[username]/(profile)/+page.svelte b/src/routes/(app)/[username]/(profile)/+page.svelte
index 2bde3e08..9c0d2203 100644
--- a/src/routes/(app)/[username]/(profile)/+page.svelte
+++ b/src/routes/(app)/[username]/(profile)/+page.svelte
@@ -5,7 +5,7 @@
import Balance from "$comp/Balance.svelte";
import Spinner from "$comp/Spinner.svelte";
import { t } from "$lib/translations";
- import { installPrompt, password } from "$lib/store";
+ import { installPrompt, password, cachedAccounts, cachedRates } from "$lib/store";
import { afterNavigate, goto, invalidate, preloadData } from "$app/navigation";
import { page } from "$app/stores";
import { versions, post, fail, getCookie } from "$lib/utils";
@@ -84,6 +84,12 @@
$effect(() => {
if (user) user.savings = 0;
});
+ $effect(() => {
+ if (accounts?.length) $cachedAccounts = accounts;
+ });
+ $effect(() => {
+ if (rates) $cachedRates = rates;
+ });
let { host } = $derived($page.url);
</script>
diff --git a/src/routes/(app)/payments/[...page]/+page.svelte b/src/routes/(app)/payments/[...page]/+page.svelte
index 1d5a0407..afce7711 100644
--- a/src/routes/(app)/payments/[...page]/+page.svelte
+++ b/src/routes/(app)/payments/[...page]/+page.svelte
@@ -3,7 +3,7 @@
import Payments from "$comp/Payments.svelte";
import { onMount } from "svelte";
import { format } from "date-fns";
- import { newPayment } from "$lib/store";
+ import { newPayment, cachedPayments } from "$lib/store";
import { t } from "$lib/translations";
import { get, f, s, si, sat, loc, sats, types } from "$lib/utils";
import { page } from "$app/stores";
@@ -55,6 +55,9 @@
invalidate(`/users/${user.username}`);
}
});
+ $effect(() => {
+ if (payments?.length && (!p || p === "1")) $cachedPayments = payments;
+ });
let path = $derived(
$page.params.page
diff --git a/src/service-worker.js b/src/service-worker.js
index a366b9d8..3c1ae0ac 100644
--- a/src/service-worker.js
+++ b/src/service-worker.js
@@ -1,18 +1,110 @@
+import { build, files, version } from "$service-worker";
import { Worker } from "@arkade-os/sdk";
const worker = new Worker();
-// Only add message listener - we handle install/activate ourselves
worker.start(false).catch(console.error);
-self.addEventListener("install", () => {
- self.skipWaiting();
+const CACHE_BUILD = `coinos-build-${version}`;
+const CACHE_PAGES = `coinos-pages-${version}`;
+const CACHE_PUBLIC = `coinos-public-${version}`;
+
+const ESSENTIAL_STATIC = files.filter(
+ (f) =>
+ f.endsWith(".woff2") ||
+ f.endsWith(".woff") ||
+ f.endsWith(".png") ||
+ f.endsWith(".webp") ||
+ f.endsWith(".webmanifest") ||
+ f === "/favicon.ico",
+);
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches
+ .open(CACHE_BUILD)
+ .then((cache) => cache.addAll([...build, ...ESSENTIAL_STATIC]))
+ .then(() => self.skipWaiting()),
+ );
});
self.addEventListener("activate", (event) => {
- event.waitUntil(self.clients.claim());
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(
+ keys
+ .filter((k) => k.startsWith("coinos-") && k !== CACHE_BUILD && k !== CACHE_PAGES && k !== CACHE_PUBLIC)
+ .map((k) => caches.delete(k)),
+ ),
+ )
+ .then(() => self.clients.claim()),
+ );
});
-self.addEventListener("fetch", () => {});
+self.addEventListener("fetch", (event) => {
+ const { request } = event;
+ const url = new URL(request.url);
+
+ if (request.method !== "GET") return;
+ if (url.origin !== self.location.origin) return;
+
+ // Build assets — cache-first (fingerprinted/immutable)
+ if (build.includes(url.pathname)) {
+ event.respondWith(caches.match(request).then((cached) => cached || fetch(request)));
+ return;
+ }
+
+ // Navigation HTML — network-first
+ if (request.mode === "navigate") {
+ event.respondWith(
+ fetch(request)
+ .then((response) => {
+ const clone = response.clone();
+ caches.open(CACHE_PAGES).then((cache) => cache.put(request, clone));
+ return response;
+ })
+ .catch(() => caches.match(request).then((cached) => cached || caches.match("/"))),
+ );
+ return;
+ }
+
+ // __data.json (SvelteKit client-side navigation) — network-first
+ if (url.pathname.endsWith("__data.json")) {
+ event.respondWith(
+ fetch(request)
+ .then((response) => {
+ const clone = response.clone();
+ caches.open(CACHE_PAGES).then((cache) => cache.put(request, clone));
+ return response;
+ })
+ .catch(() => caches.match(request)),
+ );
+ return;
+ }
+
+ // Public API (avatars etc) — cache-first
+ if (url.pathname.startsWith("/api/public/")) {
+ event.respondWith(
+ caches.match(request).then(
+ (cached) =>
+ cached ||
+ fetch(request).then((response) => {
+ const clone = response.clone();
+ caches.open(CACHE_PUBLIC).then((cache) => cache.put(request, clone));
+ return response;
+ }),
+ ),
+ );
+ return;
+ }
+
+ // Static files from ESSENTIAL_STATIC — cache-first
+ if (ESSENTIAL_STATIC.includes(url.pathname)) {
+ event.respondWith(caches.match(request).then((cached) => cached || fetch(request)));
+ return;
+ }
+});
self.addEventListener("push", (event) => {
const { title, body, url } = event.data.json();