Skip to content

Commit a1c7e9c

Browse files
committed
feat(web): PWA support with offline area downloads
Lay the groundwork for using OpenMapX as an installed app on a phone. Touches the manifest, service worker, an offline fallback page, and adds a Settings surface for downloading map areas to use without a network. None of this existed before; the existing serwist setup only ran-time-cached the assets the user happened to fetch. What's added - Web app manifest (`app/manifest.ts`) with icons, shortcuts, share_target (`/share`), `protocol_handlers` for `geo:`/`web+maps:`, and `launch_handler: focus-existing`. Placeholder icon set in `public/icons/app/` (replace with branded artwork later). - Viewport / themeColor / apple-web-app metadata in `layout.tsx` and `--omx-safe-*` CSS variables wired into every floating chrome element so the UI doesn't get eaten by notches in standalone mode. - An /offline page served as the SW navigation fallback. - A /settings/offline page where the user picks a bbox + zoom range on an embedded MapLibre instance and downloads the tiles, sprite, glyphs, TileJSON, and style JSON for both light and dark variants into a per-area Cache Storage entry. Covered styles are deduped across variants so theme switches don't double-download. - A Storage dialog (from the hamburger menu) showing `navigator.storage.estimate()` and per-cache sizes, plus a "Clear cached tiles" action that preserves user-pinned offline areas. - An "Install OpenMapX" entry that captures `beforeinstallprompt` (listener attached at module load, not in the Drawer subtree, so the event isn't lost). On iOS the entry opens an Add-to-Home-Screen hint dialog rendered as a sibling of the Drawer so the dialog survives the drawer's unmount. - A `SwUpdateNotice` Snackbar wired to serwist's `waiting` event; reload only fires on `controlling` events whose `isUpdate` is true, so first installs don't surprise the user with a reload. - A Web Share Target endpoint (`/share`) that extracts a query from `text` / `url` / `title` (treating blanks as missing) and 303's back to `/?q=…`. ShareIntentHandler consumes `?q`, `?geo`, and `?action`, then strips them from the URL. Re-share of the same URL is processed correctly — the dedup signature is reset whenever the params disappear. Service worker - `withOfflineFirst(strategy)` wrapper: any handler that opted in consults user-downloaded `offline-area-*` caches before falling through to the runtime strategy. Applied to MapTiler tiles, self-hosted vector tiles (.pbf), and a new `/styles/*` rule that also covers self-hosted style JSON / sprite / TileJSON for `NEXT_PUBLIC_MAP_STYLE_URL` deployments. - Auth deny rule (`/api/auth/*` → `fetch(request)` passthrough) so the SW never caches credential-bearing responses. - The app-shell cache (`app-shell-v1`) now precaches `/`, `/offline`, and `/manifest.webmanifest`. The navigation handler tries the exact URL in app-shell before falling back to /offline, so users with downloaded areas reach the map even after the runtime `pages` cache has expired. - `setLocaleAndReload` refreshes the cached `/` and `/offline` responses in-place after a language switch (the cookie is set first, so the responses are localized) instead of wiping the cache. - `skipWaiting: false`; the page posts a `SKIP_WAITING` message when the user accepts the update Snackbar. - The downloader bails on `QuotaExceededError` and `TypeError` (network drop) instead of swallowing them, so a half-downloaded area no longer falsely reports "ready". Build / deploy - Custom esbuild path for `src/sw.ts` is kept and documented inline. `tsconfig.sw.json` adds the `webworker` lib for that file only; `pnpm check-types` runs both projects. - Traefik gets two dynamic middlewares: `sw-no-cache@file` for `/sw.js` and `manifest-short-cache@file` for `/manifest.webmanifest`, applied via `additionalRoutes` on the `app-web` service so an upstream CDN can't pin a stale worker through a deploy. Persistence - TanStack Query cache is persisted to localStorage via `@tanstack/query-sync-storage-persister` with an allowlist of query roots (`place`, `weather`, `nearby`, `isochrone`, `sun-times`, `directions`, `route`, `geocode`). Admin / live data re-fetches from network as before.
1 parent 1dcc6d8 commit a1c7e9c

54 files changed

Lines changed: 2536 additions & 95 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"build:sw": "esbuild src/sw.ts --bundle --outfile=public/sw.js --format=iife --platform=browser --define:process.env.NODE_ENV='\"production\"'",
1010
"build:copy-assets": "mkdir -p .next/standalone/apps/web/public .next/standalone/apps/web/.next/static && cp -R public/. .next/standalone/apps/web/public/ && cp -R .next/static/. .next/standalone/apps/web/.next/static/",
1111
"start": "node .next/standalone/apps/web/server.js",
12-
"check-types": "tsc --noEmit",
12+
"check-types": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
1313
"icons:sync": "node scripts/copy-preset-icons.mjs"
1414
},
1515
"dependencies": {
@@ -31,7 +31,9 @@
3131
"@openmapx/i18n": "workspace:*",
3232
"@serwist/next": "^9.5.10",
3333
"@serwist/window": "^9.5.10",
34+
"@tanstack/query-sync-storage-persister": "^5.100.9",
3435
"@tanstack/react-query": "^5.100.7",
36+
"@tanstack/react-query-persist-client": "^5.100.9",
3537
"@turf/area": "^7.3.5",
3638
"@turf/helpers": "^7.3.5",
3739
"@turf/length": "^7.3.5",
4.15 KB
Loading
1.07 KB
Loading
4.38 KB
Loading
6.75 KB
Loading
5.41 KB
Loading
4.04 KB
Loading

apps/web/src/app/(map)/layout.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
import { OfflineNotice } from "@/components/OfflineNotice";
2-
31
export default function MapLayout({ children }: { children: React.ReactNode }) {
4-
return (
5-
<div className="relative w-full h-full overflow-hidden">
6-
{children}
7-
<OfflineNotice />
8-
</div>
9-
);
2+
return <div className="relative w-full h-full overflow-hidden">{children}</div>;
103
}

apps/web/src/app/globals.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
--omx-shadow-soft: rgba(0, 0, 0, 0.15);
1515
--omx-overlay-bg: #fff;
1616
--omx-overlay-text: #000;
17+
--omx-safe-top: env(safe-area-inset-top, 0px);
18+
--omx-safe-bottom: env(safe-area-inset-bottom, 0px);
19+
--omx-safe-left: env(safe-area-inset-left, 0px);
20+
--omx-safe-right: env(safe-area-inset-right, 0px);
1721
}
1822

1923
.dark {

apps/web/src/app/layout.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import InitColorSchemeScript from "@mui/material/InitColorSchemeScript";
22
import { AppRouterCacheProvider } from "@mui/material-nextjs/v16-appRouter";
3-
import type { Metadata } from "next";
3+
import type { Metadata, Viewport } from "next";
44
import { Plus_Jakarta_Sans } from "next/font/google";
55
import { NextIntlClientProvider } from "next-intl";
66
import { getLocale, getMessages } from "next-intl/server";
77
import "mapillary-js/dist/mapillary.css";
88
import "maplibre-theme/icons.default.css";
99
import "maplibre-theme/classic.css";
1010
import "./globals.css";
11+
import { OfflineNotice } from "@/components/OfflineNotice";
12+
import { SwUpdateNotice } from "@/components/pwa/SwUpdateNotice";
13+
import { InstallPromptCapture } from "@/components/pwa/useInstallPrompt";
1114
import { EnvProvider } from "@/lib/EnvProvider";
1215
import { buildClientEnv } from "@/lib/env";
1316
import { Providers } from "./providers";
@@ -22,6 +25,34 @@ const plusJakartaSans = Plus_Jakarta_Sans({
2225
export const metadata: Metadata = {
2326
title: "OpenMapX",
2427
description: "Open-data maps — a Google Maps alternative",
28+
applicationName: "OpenMapX",
29+
appleWebApp: {
30+
capable: true,
31+
title: "OpenMapX",
32+
statusBarStyle: "black-translucent",
33+
},
34+
formatDetection: {
35+
telephone: false,
36+
},
37+
icons: {
38+
icon: [
39+
{ url: "/icons/app/favicon-32.png", sizes: "32x32", type: "image/png" },
40+
{ url: "/icons/app/icon-192.png", sizes: "192x192", type: "image/png" },
41+
{ url: "/icons/app/icon-512.png", sizes: "512x512", type: "image/png" },
42+
],
43+
apple: [{ url: "/icons/app/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
44+
},
45+
};
46+
47+
export const viewport: Viewport = {
48+
width: "device-width",
49+
initialScale: 1,
50+
maximumScale: 5,
51+
viewportFit: "cover",
52+
themeColor: [
53+
{ media: "(prefers-color-scheme: light)", color: "#007b8b" },
54+
{ media: "(prefers-color-scheme: dark)", color: "#1c1c1c" },
55+
],
2556
};
2657

2758
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
@@ -39,7 +70,18 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
3970
<AppRouterCacheProvider>
4071
<NextIntlClientProvider locale={locale} messages={messages}>
4172
<EnvProvider config={clientEnv}>
42-
<Providers>{children}</Providers>
73+
<Providers>
74+
{children}
75+
{/* Mounted at the root so the SW registers, the offline
76+
notice surfaces on every route, and the install-prompt
77+
listener attaches before `beforeinstallprompt` fires (it
78+
would be missed if it lived inside the HamburgerMenu's
79+
temporary Drawer subtree). Each component is a client
80+
island with its own production / serviceWorker guards. */}
81+
<OfflineNotice />
82+
<SwUpdateNotice />
83+
<InstallPromptCapture />
84+
</Providers>
4385
</EnvProvider>
4486
</NextIntlClientProvider>
4587
</AppRouterCacheProvider>

0 commit comments

Comments
 (0)