Add PWA support with manifest, service worker, and icons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -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;
|
||||||
|
|||||||
BIN
frontend/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
frontend/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
frontend/public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
frontend/public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
90
frontend/public/sw.js
Normal 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;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
35
frontend/src/app/manifest.ts
Normal 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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
15
frontend/src/components/sw-registrar.tsx
Normal 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;
|
||||||
|
}
|
||||||