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); + }); + }); + } })();