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} */
|
||||
const nextConfig = {
|
||||
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;
|
||||
|
||||
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 type { Viewport } from "next";
|
||||
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 = {
|
||||
title: "Notify",
|
||||
description: "简洁提醒应用",
|
||||
icons: {
|
||||
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 }) => {
|
||||
return (
|
||||
<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">
|
||||
<I18nProvider>{children}</I18nProvider>
|
||||
<ServiceWorkerRegistrar />
|
||||
</body>
|
||||
</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;
|
||||
}
|
||||