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.
This commit is contained in:
Claude 2025-11-19 03:20:21 +00:00
parent 4de8712cb0
commit 7717f84afe
No known key found for this signature in database
9 changed files with 222 additions and 4 deletions

View File

@ -6,6 +6,10 @@
<link rel="icon" type="image/webp" href="/logo.webp" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="A lightweight, self-hosted knowledge management and note-taking platform" />
<!-- memos.metadata.head -->
<title>Memos</title>
</head>

View File

@ -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": "/"
}

94
web/public/sw.js Normal file
View File

@ -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",
}),
});
});
})
);
});

View File

@ -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 <Outlet />;
return (
<>
<OfflineIndicator />
<Outlet />
</>
);
});
export default App;

View File

@ -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 = `
<div class="text-center p-4">
<svg class="w-12 h-12 mx-auto mb-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"></path>
</svg>
<p class="text-gray-600 dark:text-gray-300 font-medium">Map tiles unavailable offline</p>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">You can still set coordinates by clicking</p>
</div>
`;
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 (
<MapContainer className="w-full h-72" center={position} zoom={13} scrollWheelZoom={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
eventHandlers={{
tileerror: () => {
// Silently handle tile loading errors in offline mode
},
}}
/>
{isOffline && <OfflineMapOverlay />}
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
</MapContainer>
);

View File

@ -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)",

View File

@ -0,0 +1,27 @@
import { useOfflineDetection } from "@/hooks/useOfflineDetection";
const OfflineIndicator = () => {
const isOffline = useOfflineDetection();
if (!isOffline) {
return null;
}
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-yellow-500 text-white px-4 py-2 text-center text-sm font-medium shadow-md">
<span className="inline-flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"
/>
</svg>
Offline Mode - Some features (maps, OAuth login, webhooks) may be unavailable
</span>
</div>
);
};
export default OfflineIndicator;

View File

@ -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;
};

View File

@ -30,4 +30,18 @@ const Main = observer(() => (
const container = document.getElementById("root");
const root = createRoot(container as HTMLElement);
root.render(<Main />);
// 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);
});
});
}
})();