diff --git a/frontend/index.html b/frontend/index.html
index 08c760964f..f70e667ed0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -5,6 +5,10 @@
Nginx Proxy Manager
+
+
+
+
-
+
diff --git a/frontend/public/images/favicon/site.webmanifest b/frontend/public/images/favicon/site.webmanifest
index 99d1016eb5..20dab70f04 100644
--- a/frontend/public/images/favicon/site.webmanifest
+++ b/frontend/public/images/favicon/site.webmanifest
@@ -1,19 +1,25 @@
{
- "name": "",
- "short_name": "",
- "icons": [
- {
- "src": "/images/favicons/android-chrome-192x192.png",
- "sizes": "192x192",
- "type": "image/png"
- },
- {
- "src": "/images/favicons/android-chrome-512x512.png",
- "sizes": "512x512",
- "type": "image/png"
- }
- ],
- "theme_color": "#ffffff",
- "background_color": "#ffffff",
- "display": "standalone"
+ "name": "Nginx Proxy Manager",
+ "short_name": "NPM",
+ "description": "Manage Nginx hosts with a simple, powerful interface",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "theme_color": "#206bc4",
+ "background_color": "#ffffff",
+ "icons": [
+ {
+ "src": "/images/favicon/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/images/favicon/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ]
}
diff --git a/frontend/public/offline.html b/frontend/public/offline.html
new file mode 100644
index 0000000000..75d311b990
--- /dev/null
+++ b/frontend/public/offline.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+ Nginx Proxy Manager is offline
+
+
+
+
+
+ Nginx Proxy Manager is offline
+ The app shell is installed, but management actions require a connection to the NPM server.
+
+
+
+
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 0000000000..a6fd1b4a93
--- /dev/null
+++ b/frontend/public/sw.js
@@ -0,0 +1,72 @@
+const CACHE_VERSION = "npm-pwa-v1";
+const APP_SHELL = [
+ "/",
+ "/index.html",
+ "/offline.html",
+ "/images/logo-no-text.svg",
+ "/images/favicon/android-chrome-192x192.png",
+ "/images/favicon/android-chrome-512x512.png",
+];
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(CACHE_VERSION).then((cache) => cache.addAll(APP_SHELL)),
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key))))
+ .then(() => self.clients.claim()),
+ );
+});
+
+self.addEventListener("fetch", (event) => {
+ const { request } = event;
+
+ if (request.method !== "GET") {
+ return;
+ }
+
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin || url.pathname.startsWith("/api/")) {
+ return;
+ }
+
+ if (request.mode === "navigate") {
+ event.respondWith(
+ fetch(request)
+ .then((response) => {
+ const copy = response.clone();
+ caches.open(CACHE_VERSION).then((cache) => cache.put("/index.html", copy));
+ return response;
+ })
+ .catch(async () => (await caches.match("/index.html")) || caches.match("/offline.html")),
+ );
+ return;
+ }
+
+ event.respondWith(
+ caches.match(request).then((cached) => {
+ if (cached) {
+ return cached;
+ }
+
+ return fetch(request).then((response) => {
+ if (response.ok) {
+ const copy = response.clone();
+ caches.open(CACHE_VERSION).then((cache) => cache.put(request, copy));
+ }
+ return response;
+ });
+ }),
+ );
+});
+
+self.addEventListener("message", (event) => {
+ if (event.data?.type === "SKIP_WAITING") {
+ self.skipWaiting();
+ }
+});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b6f0bba70d..df98622267 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,16 +1,34 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useEffect } from "react";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import EasyModal from "ez-modal-react";
import { RawIntlProvider } from "react-intl";
-import { ToastContainer } from "react-toastify";
+import { toast, ToastContainer } from "react-toastify";
import { AuthProvider, LocaleProvider, ThemeProvider } from "src/context";
import { intl } from "src/locale";
import Router from "src/Router.tsx";
+import { registerPwa } from "src/modules/Pwa";
// Create a client
const queryClient = new QueryClient();
function App() {
+ useEffect(() => {
+ registerPwa({
+ onOfflineReady: () => {
+ toast.info("Nginx Proxy Manager is ready for offline launch.");
+ },
+ onUpdateReady: (activateUpdate) => {
+ toast.info(
+ ,
+ { autoClose: false },
+ );
+ },
+ });
+ }, []);
+
return (
diff --git a/frontend/src/modules/Pwa.ts b/frontend/src/modules/Pwa.ts
new file mode 100644
index 0000000000..003f7c3343
--- /dev/null
+++ b/frontend/src/modules/Pwa.ts
@@ -0,0 +1,63 @@
+type RegisterPwaOptions = {
+ onOfflineReady?: () => void;
+ onUpdateReady?: (activateUpdate: () => void) => void;
+};
+
+function listenForWaitingWorker(registration: ServiceWorkerRegistration, onUpdateReady?: (activateUpdate: () => void) => void) {
+ const notify = (worker?: ServiceWorker | null) => {
+ if (!worker || !onUpdateReady) {
+ return;
+ }
+
+ onUpdateReady(() => worker.postMessage({ type: "SKIP_WAITING" }));
+ };
+
+ if (registration.waiting) {
+ notify(registration.waiting);
+ }
+
+ registration.addEventListener("updatefound", () => {
+ const worker = registration.installing;
+ worker?.addEventListener("statechange", () => {
+ if (worker.state === "installed" && navigator.serviceWorker.controller) {
+ notify(worker);
+ }
+ });
+ });
+}
+
+export function registerPwa({ onOfflineReady, onUpdateReady }: RegisterPwaOptions = {}) {
+ if (!import.meta.env.PROD || !("serviceWorker" in navigator)) {
+ return;
+ }
+
+ window.addEventListener("load", () => {
+ navigator.serviceWorker
+ .register("/sw.js")
+ .then((registration) => {
+ listenForWaitingWorker(registration, onUpdateReady);
+ if (!navigator.serviceWorker.controller) {
+ registration.addEventListener("updatefound", () => {
+ const worker = registration.installing;
+ worker?.addEventListener("statechange", () => {
+ if (worker.state === "installed") {
+ onOfflineReady?.();
+ }
+ });
+ });
+ }
+ })
+ .catch((error) => {
+ console.error("Failed to register service worker", error);
+ });
+ });
+
+ let refreshing = false;
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
+ if (refreshing) {
+ return;
+ }
+ refreshing = true;
+ window.location.reload();
+ });
+}