feat: add PWA support

This commit is contained in:
NightWatcher 2026-06-20 15:23:07 +08:00
parent 7f0f6137e0
commit ecdf901dc4
6 changed files with 240 additions and 19 deletions

View file

@ -5,6 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nginx Proxy Manager</title>
<meta name="description" content="Manage Nginx hosts with a simple, powerful interface" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="NPM" />
<meta name="application-name" content="Nginx Proxy Manager" />
<link rel="preload" href="/images/logo-no-text.svg" as="image" type="image/svg+xml" fetchPriority="high">
<link
rel="apple-touch-icon"
@ -30,7 +34,7 @@
<meta
name="msapplication-config"
content="/images/favicon/browserconfig.xml" />
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#206bc4" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View file

@ -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"
}
]
}

View file

@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#206bc4" />
<title>Nginx Proxy Manager is offline</title>
<style>
:root { color-scheme: light dark; }
body {
align-items: center;
background: #f6f8fb;
color: #1f2937;
display: flex;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
justify-content: center;
min-height: 100vh;
margin: 0;
padding: 1.5rem;
}
main {
background: #fff;
border: 1px solid #dce2ea;
border-radius: 14px;
box-shadow: 0 12px 32px rgb(0 0 0 / 8%);
max-width: 28rem;
padding: 2rem;
text-align: center;
}
img { height: 4.5rem; margin-bottom: 1rem; }
h1 { font-size: 1.4rem; margin: 0 0 .5rem; }
p { color: #4b5563; line-height: 1.5; margin: 0 0 1.25rem; }
button {
background: #206bc4;
border: 0;
border-radius: 8px;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: .65rem 1rem;
}
@media (prefers-color-scheme: dark) {
body { background: #111827; color: #f9fafb; }
main { background: #1f2937; border-color: #374151; }
p { color: #d1d5db; }
}
</style>
</head>
<body>
<main>
<img src="/images/logo-no-text.svg" alt="" />
<h1>Nginx Proxy Manager is offline</h1>
<p>The app shell is installed, but management actions require a connection to the NPM server.</p>
<button type="button" onclick="window.location.reload()">Try again</button>
</main>
</body>
</html>

72
frontend/public/sw.js Normal file
View file

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

View file

@ -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(
<button className="btn btn-primary btn-sm" type="button" onClick={activateUpdate}>
Update available. Reload
</button>,
{ autoClose: false },
);
},
});
}, []);
return (
<RawIntlProvider value={intl}>
<LocaleProvider>

View file

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