diff --git a/frontend/next.config.js b/frontend/next.config.js index 7a8c8da..a8dced9 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -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; diff --git a/frontend/public/icons/icon-128x128.png b/frontend/public/icons/icon-128x128.png new file mode 100644 index 0000000..c785e4e Binary files /dev/null and b/frontend/public/icons/icon-128x128.png differ diff --git a/frontend/public/icons/icon-144x144.png b/frontend/public/icons/icon-144x144.png new file mode 100644 index 0000000..5550b97 Binary files /dev/null and b/frontend/public/icons/icon-144x144.png differ diff --git a/frontend/public/icons/icon-152x152.png b/frontend/public/icons/icon-152x152.png new file mode 100644 index 0000000..24ae892 Binary files /dev/null and b/frontend/public/icons/icon-152x152.png differ diff --git a/frontend/public/icons/icon-192x192.png b/frontend/public/icons/icon-192x192.png new file mode 100644 index 0000000..6237d28 Binary files /dev/null and b/frontend/public/icons/icon-192x192.png differ diff --git a/frontend/public/icons/icon-384x384.png b/frontend/public/icons/icon-384x384.png new file mode 100644 index 0000000..5f43849 Binary files /dev/null and b/frontend/public/icons/icon-384x384.png differ diff --git a/frontend/public/icons/icon-512x512.png b/frontend/public/icons/icon-512x512.png new file mode 100644 index 0000000..a73b722 Binary files /dev/null and b/frontend/public/icons/icon-512x512.png differ diff --git a/frontend/public/icons/icon-72x72.png b/frontend/public/icons/icon-72x72.png new file mode 100644 index 0000000..b2540b9 Binary files /dev/null and b/frontend/public/icons/icon-72x72.png differ diff --git a/frontend/public/icons/icon-96x96.png b/frontend/public/icons/icon-96x96.png new file mode 100644 index 0000000..d465e5f Binary files /dev/null and b/frontend/public/icons/icon-96x96.png differ diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..2b87e6a --- /dev/null +++ b/frontend/public/sw.js @@ -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; + }); + }) + ) + ); +}); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 621e796..803d4dd 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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 ( + + + {children} + ); diff --git a/frontend/src/app/manifest.ts b/frontend/src/app/manifest.ts new file mode 100644 index 0000000..dbad361 --- /dev/null +++ b/frontend/src/app/manifest.ts @@ -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", + }, + ], + }; +} diff --git a/frontend/src/components/sw-registrar.tsx b/frontend/src/components/sw-registrar.tsx new file mode 100644 index 0000000..d93345c --- /dev/null +++ b/frontend/src/components/sw-registrar.tsx @@ -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; +}