Add PWA support with manifest, service worker, and icons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Dong
2026-02-27 12:14:47 +08:00
parent 8131ec7af2
commit af194d1b9c
13 changed files with 184 additions and 2 deletions

View File

@@ -1,7 +1,24 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
output: 'standalone' output: 'standalone',
async headers() {
return [
{
source: '/sw.js',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=0, must-revalidate',
},
{
key: 'Service-Worker-Allowed',
value: '/',
},
],
},
];
},
}; };
module.exports = nextConfig; module.exports = nextConfig;

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

90
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,90 @@
const CACHE_NAME = "notify-v1";
const PRECACHE_URLS = ["/"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((names) =>
Promise.all(
names
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
)
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
// Skip cross-origin and non-GET requests
if (request.method !== "GET" || !request.url.startsWith(self.location.origin)) {
return;
}
const url = new URL(request.url);
// Skip API calls
if (url.pathname.startsWith("/api/")) {
return;
}
// Next.js static chunks — stale-while-revalidate
if (url.pathname.startsWith("/_next/")) {
event.respondWith(
caches.open(CACHE_NAME).then((cache) =>
cache.match(request).then((cached) => {
const fetched = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
});
return cached || fetched;
})
)
);
return;
}
// Navigation requests — network-first with cache fallback
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return response;
})
.catch(() => caches.match(request))
);
return;
}
// Other static assets — cache-first
event.respondWith(
caches.open(CACHE_NAME).then((cache) =>
cache.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
});
})
)
);
});

View File

@@ -1,21 +1,46 @@
import "./globals.css"; import "./globals.css";
import type { Viewport } from "next";
import { I18nProvider } from "@/lib/i18n"; import { I18nProvider } from "@/lib/i18n";
import { ServiceWorkerRegistrar } from "@/components/sw-registrar";
export const viewport: Viewport = {
themeColor: "#2563EB",
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export const metadata = { export const metadata = {
title: "Notify", title: "Notify",
description: "简洁提醒应用", description: "简洁提醒应用",
icons: { icons: {
icon: "/icon.png", icon: "/icon.png",
apple: "/apple-icon.png", apple: "/icons/icon-192x192.png",
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Notify",
},
formatDetection: {
telephone: false,
}, },
}; };
const RootLayout = ({ children }: { children: React.ReactNode }) => { const RootLayout = ({ children }: { children: React.ReactNode }) => {
return ( return (
<html lang="zh" suppressHydrationWarning> <html lang="zh" suppressHydrationWarning>
<head>
<link
rel="apple-touch-startup-image"
href="/icons/icon-512x512.png"
/>
</head>
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<I18nProvider>{children}</I18nProvider> <I18nProvider>{children}</I18nProvider>
<ServiceWorkerRegistrar />
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,35 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Notify",
short_name: "Notify",
description: "简洁提醒应用",
start_url: "/",
display: "standalone",
background_color: "#F1F3F8",
theme_color: "#2563EB",
icons: [
{ src: "/icons/icon-72x72.png", sizes: "72x72", type: "image/png" },
{ src: "/icons/icon-96x96.png", sizes: "96x96", type: "image/png" },
{ src: "/icons/icon-128x128.png", sizes: "128x128", type: "image/png" },
{ src: "/icons/icon-144x144.png", sizes: "144x144", type: "image/png" },
{ src: "/icons/icon-152x152.png", sizes: "152x152", type: "image/png" },
{ src: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-384x384.png", sizes: "384x384", type: "image/png" },
{ src: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png" },
{
src: "/icons/icon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icons/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}

View File

@@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegistrar() {
useEffect(() => {
if ("serviceWorker" in navigator && process.env.NODE_ENV === "production") {
navigator.serviceWorker.register("/sw.js").catch((err) => {
console.warn("SW registration failed:", err);
});
}
}, []);
return null;
}