mirror of https://github.com/usememos/memos.git
Merge pull request #1 from hax429/claude/enable-offline-mode-012ESoD6URUW86A7YxtdJGpb
feat(web): enable offline mode support
This commit is contained in:
commit
865e0ff962
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": "/"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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<IdentityProvider[]>([]);
|
||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||
|
||||
|
|
@ -87,7 +89,7 @@ const SignIn = observer(() => {
|
|||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{identityProviderList.length > 0 && (
|
||||
{identityProviderList.length > 0 && !isOffline && (
|
||||
<>
|
||||
{!instanceGeneralSetting.disallowPasswordAuth && (
|
||||
<div className="relative my-4 w-full">
|
||||
|
|
|
|||
Loading…
Reference in New Issue