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