From 7717f84afec31d5b201b95803eca53bb2613e285 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Nov 2025 03:20:21 +0000 Subject: [PATCH 1/2] feat(web): enable offline mode support Add comprehensive offline mode support to allow the app to run locally without internet connectivity: - Add offline detection hook (useOfflineDetection) - Add visual offline indicator banner in UI - Add PWA support with enhanced manifest and service worker - Update map component to gracefully handle offline state with overlay - Skip geocoding API calls when offline, falling back to coordinates - Add PWA meta tags for better mobile app experience - Service worker caches app shell and runtime resources The app now handles internet-dependent features gracefully: - Maps display offline message but still allow coordinate selection - Geocoding falls back to showing coordinates when offline - Webhooks already log errors without failing operations - OAuth providers will fail gracefully when offline Core functionality (creating/editing memos with local auth) works fully offline when backend is accessible locally. --- web/index.html | 4 + web/public/site.webmanifest | 7 +- web/public/sw.js | 94 +++++++++++++++++++ web/src/App.tsx | 8 +- web/src/components/LeafletMap.tsx | 42 ++++++++- .../MemoEditor/ActionButton/InsertMenu.tsx | 6 ++ web/src/components/OfflineIndicator.tsx | 27 ++++++ web/src/hooks/useOfflineDetection.ts | 24 +++++ web/src/main.tsx | 14 +++ 9 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 web/public/sw.js create mode 100644 web/src/components/OfflineIndicator.tsx create mode 100644 web/src/hooks/useOfflineDetection.ts diff --git a/web/index.html b/web/index.html index 80133402b..744d84f34 100644 --- a/web/index.html +++ b/web/index.html @@ -6,6 +6,10 @@ + + + + Memos diff --git a/web/public/site.webmanifest b/web/public/site.webmanifest index 7c93a8ffa..0dd2878b4 100644 --- a/web/public/site.webmanifest +++ b/web/public/site.webmanifest @@ -1,10 +1,15 @@ { "name": "Memos", "short_name": "Memos", + "description": "A lightweight, self-hosted knowledge management and note-taking platform", "icons": [ { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } ], "display": "standalone", - "start_url": "/" + "start_url": "/", + "theme_color": "#000000", + "background_color": "#ffffff", + "orientation": "any", + "scope": "/" } diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 000000000..e20ca9fa7 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,94 @@ +// Service Worker for Memos offline support +const CACHE_NAME = "memos-cache-v1"; +const RUNTIME_CACHE = "memos-runtime-cache-v1"; + +// Assets to cache on install +const PRECACHE_ASSETS = [ + "/", + "/logo.webp", + "/apple-touch-icon.png", + "/android-chrome-192x192.png", + "/android-chrome-512x512.png", +]; + +// Install event - cache essential assets +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(PRECACHE_ASSETS).catch((error) => { + console.error("Failed to cache assets during install:", error); + }); + }) + ); + self.skipWaiting(); +}); + +// Activate event - cleanup old caches +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) { + return caches.delete(cacheName); + } + }) + ); + }) + ); + self.clients.claim(); +}); + +// Fetch event - network first, fallback to cache +self.addEventListener("fetch", (event) => { + // Skip cross-origin requests + if (!event.request.url.startsWith(self.location.origin)) { + return; + } + + // Skip non-GET requests + if (event.request.method !== "GET") { + return; + } + + event.respondWith( + fetch(event.request) + .then((response) => { + // Don't cache if not a valid response + if (!response || response.status !== 200 || response.type === "error") { + return response; + } + + // Clone the response + const responseToCache = response.clone(); + + // Cache the fetched response + caches.open(RUNTIME_CACHE).then((cache) => { + cache.put(event.request, responseToCache); + }); + + return response; + }) + .catch(() => { + // Network failed, try cache + return caches.match(event.request).then((response) => { + if (response) { + return response; + } + + // If not in cache and offline, return a basic offline page for navigation requests + if (event.request.mode === "navigate") { + return caches.match("/"); + } + + return new Response("Offline - resource not available", { + status: 503, + statusText: "Service Unavailable", + headers: new Headers({ + "Content-Type": "text/plain", + }), + }); + }); + }) + ); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index df13766ce..a7a3d7cd7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react-lite"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Outlet } from "react-router-dom"; +import OfflineIndicator from "./components/OfflineIndicator"; import useNavigateTo from "./hooks/useNavigateTo"; import { instanceStore, userStore } from "./store"; import { cleanupExpiredOAuthState } from "./utils/oauth"; @@ -104,7 +105,12 @@ const App = observer(() => { return cleanup; }, [userGeneralSetting?.theme, instanceStore.state.theme]); - return ; + return ( + <> + + + + ); }); export default App; diff --git a/web/src/components/LeafletMap.tsx b/web/src/components/LeafletMap.tsx index f71122ce4..1d7ff5aa1 100644 --- a/web/src/components/LeafletMap.tsx +++ b/web/src/components/LeafletMap.tsx @@ -2,7 +2,8 @@ import { DivIcon, LatLng } from "leaflet"; import { MapPinIcon } from "lucide-react"; import { useEffect, useState } from "react"; import ReactDOMServer from "react-dom/server"; -import { MapContainer, Marker, TileLayer, useMapEvents } from "react-leaflet"; +import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet"; +import { useOfflineDetection } from "@/hooks/useOfflineDetection"; const markerIcon = new DivIcon({ className: "relative border-none", @@ -58,11 +59,48 @@ interface MapProps { const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945); +const OfflineMapOverlay = () => { + const map = useMap(); + + useEffect(() => { + const container = map.getContainer(); + const overlay = document.createElement("div"); + overlay.className = + "absolute inset-0 bg-gray-100 dark:bg-zinc-800 bg-opacity-90 dark:bg-opacity-90 flex items-center justify-center z-[1000] pointer-events-none"; + overlay.innerHTML = ` +
+ + + +

Map tiles unavailable offline

+

You can still set coordinates by clicking

+
+ `; + container.appendChild(overlay); + + return () => { + container.removeChild(overlay); + }; + }, [map]); + + return null; +}; + const LeafletMap = (props: MapProps) => { const position = props.latlng || DEFAULT_CENTER_LAT_LNG; + const isOffline = useOfflineDetection(); + return ( - + { + // Silently handle tile loading errors in offline mode + }, + }} + /> + {isOffline && } {}} /> ); diff --git a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx index 6de3991b5..513622265 100644 --- a/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx +++ b/web/src/components/MemoEditor/ActionButton/InsertMenu.tsx @@ -87,6 +87,12 @@ const InsertMenu = observer((props: Props) => { const handlePositionChange = (position: LatLng) => { location.handlePositionChange(position); + // Skip geocoding if offline + if (!navigator.onLine) { + location.setPlaceholder(`${position.lat.toFixed(6)}, ${position.lng.toFixed(6)}`); + return; + } + fetch(`https://nominatim.openstreetmap.org/reverse?lat=${position.lat}&lon=${position.lng}&format=json`, { headers: { "User-Agent": "Memos/1.0 (https://github.com/usememos/memos)", diff --git a/web/src/components/OfflineIndicator.tsx b/web/src/components/OfflineIndicator.tsx new file mode 100644 index 000000000..75b62d6b2 --- /dev/null +++ b/web/src/components/OfflineIndicator.tsx @@ -0,0 +1,27 @@ +import { useOfflineDetection } from "@/hooks/useOfflineDetection"; + +const OfflineIndicator = () => { + const isOffline = useOfflineDetection(); + + if (!isOffline) { + return null; + } + + return ( +
+ + + + + Offline Mode - Some features (maps, OAuth login, webhooks) may be unavailable + +
+ ); +}; + +export default OfflineIndicator; diff --git a/web/src/hooks/useOfflineDetection.ts b/web/src/hooks/useOfflineDetection.ts new file mode 100644 index 000000000..9b4daadb0 --- /dev/null +++ b/web/src/hooks/useOfflineDetection.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +/** + * Hook to detect online/offline status + * Returns true when offline, false when online + */ +export const useOfflineDetection = () => { + const [isOffline, setIsOffline] = useState(!navigator.onLine); + + useEffect(() => { + const handleOnline = () => setIsOffline(false); + const handleOffline = () => setIsOffline(true); + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + return isOffline; +}; diff --git a/web/src/main.tsx b/web/src/main.tsx index cd747a044..2094c5533 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -30,4 +30,18 @@ const Main = observer(() => ( const container = document.getElementById("root"); const root = createRoot(container as HTMLElement); root.render(
); + + // Register service worker for offline support + if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker + .register("/sw.js") + .then((registration) => { + console.log("Service Worker registered:", registration); + }) + .catch((error) => { + console.log("Service Worker registration failed:", error); + }); + }); + } })(); From 923a346eacdda541268323070614b0b1cb5a0fc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Nov 2025 03:36:06 +0000 Subject: [PATCH 2/2] feat(web,server): improve offline mode functionality Enhance offline mode to fully support local-only operation: Frontend changes: - Hide OAuth login options when offline (SignIn.tsx) OAuth providers require external internet access and won't work offline, so they're now hidden when network is unavailable - Coordinate inputs already available in LocationDialog for manual entry Users can enter lat/lng directly when map tiles don't load Backend changes: - Add better logging for webhook network failures (webhook.go) Webhooks already fail gracefully via async goroutines, now with clearer logging to indicate network may be offline The app now functions fully offline for core features when both frontend and backend are running on local network: - Local username/password auth works (OAuth hidden) - All memo operations work (webhooks logged but don't block) - Maps allow coordinate input (tiles unavailable but functional) - Geocoding falls back to coordinates (already implemented) --- plugin/webhook/webhook.go | 5 +++++ web/src/pages/SignIn.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/plugin/webhook/webhook.go b/plugin/webhook/webhook.go index fe59d4b88..9971a1429 100644 --- a/plugin/webhook/webhook.go +++ b/plugin/webhook/webhook.go @@ -47,6 +47,11 @@ func Post(requestPayload *WebhookRequestPayload) error { } resp, err := client.Do(req) if err != nil { + // Log network errors but don't fail the operation - webhooks are optional + slog.Warn("Failed to post webhook (network may be offline)", + slog.String("url", requestPayload.URL), + slog.String("activityType", requestPayload.ActivityType), + slog.Any("err", err)) return errors.Wrapf(err, "failed to post webhook to %s", requestPayload.URL) } diff --git a/web/src/pages/SignIn.tsx b/web/src/pages/SignIn.tsx index cd44a95e4..adda92a8f 100644 --- a/web/src/pages/SignIn.tsx +++ b/web/src/pages/SignIn.tsx @@ -9,6 +9,7 @@ import { Separator } from "@/components/ui/separator"; import { identityProviderServiceClient } from "@/grpcweb"; import { absolutifyLink } from "@/helpers/utils"; import useCurrentUser from "@/hooks/useCurrentUser"; +import { useOfflineDetection } from "@/hooks/useOfflineDetection"; import { Routes } from "@/router"; import { instanceStore } from "@/store"; import { extractIdentityProviderIdFromName } from "@/store/common"; @@ -19,6 +20,7 @@ import { storeOAuthState } from "@/utils/oauth"; const SignIn = observer(() => { const t = useTranslate(); const currentUser = useCurrentUser(); + const isOffline = useOfflineDetection(); const [identityProviderList, setIdentityProviderList] = useState([]); const instanceGeneralSetting = instanceStore.state.generalSetting; @@ -87,7 +89,7 @@ const SignIn = observer(() => {

)} - {identityProviderList.length > 0 && ( + {identityProviderList.length > 0 && !isOffline && ( <> {!instanceGeneralSetting.disallowPasswordAuth && (