diff --git a/ROADMAP.md b/ROADMAP.md index 498edc5..1f08ee8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,10 +33,10 @@ - 抽中后打卡确认(拍照上传,形成回忆) - "本周契约执行率" 统计 -### PWA 支持 -- 添加 Web App Manifest,支持"添加到主屏幕" -- Service Worker 离线缓存基础页面 -- `viewport-fit=cover` 适配刘海屏 +### ~~PWA 支持~~(已完成) +- ~~添加 Web App Manifest,支持"添加到主屏幕"~~ +- ~~Service Worker 离线缓存基础页面~~ +- ~~`viewport-fit=cover` 适配刘海屏~~ ### 盲盒房间删除 / 退出 - 房间创建者可删除房间(级联清理成员 & 想法) diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..0c1d427 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/icon-192x192.png b/public/icon-192x192.png new file mode 100644 index 0000000..3fcc54e Binary files /dev/null and b/public/icon-192x192.png differ diff --git a/public/icon-512x512.png b/public/icon-512x512.png new file mode 100644 index 0000000..281918f Binary files /dev/null and b/public/icon-512x512.png differ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..f9a9905 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,68 @@ +const CACHE_NAME = "nowhatever-v1"; + +const PRECACHE_URLS = ["/", "/offline"]; + +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((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .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); + + // API calls: network-only + if (url.pathname.startsWith("/api/")) return; + + // Static assets (_next/static, icons, fonts): cache-first + if ( + url.pathname.startsWith("/_next/static/") || + url.pathname.match(/\.(png|jpg|svg|ico|woff2?)$/) + ) { + event.respondWith( + caches.match(request).then( + (cached) => + cached || + fetch(request).then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + return response; + }) + ) + ); + return; + } + + // HTML pages: network-first, fallback to cache, then offline page + 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).then((cached) => cached || caches.match("/offline"))) + ); +}); diff --git a/scripts/generate-pwa-icons.mjs b/scripts/generate-pwa-icons.mjs new file mode 100644 index 0000000..07a1676 --- /dev/null +++ b/scripts/generate-pwa-icons.mjs @@ -0,0 +1,37 @@ +import sharp from "sharp"; +import { mkdirSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const publicDir = join(__dirname, "..", "public"); + +const ACCENT = "#10b981"; +const BG_DARK = "#030712"; + +function buildSvg(size) { + const fontSize = Math.round(size * 0.32); + const radius = Math.round(size * 0.18); + return ` + + NW + `; +} + +const sizes = [192, 512]; + +for (const size of sizes) { + const svg = Buffer.from(buildSvg(size)); + const out = join(publicDir, `icon-${size}x${size}.png`); + await sharp(svg).resize(size, size).png().toFile(out); + console.log(`✓ ${out}`); +} + +const appleSvg = Buffer.from(buildSvg(180)); +const appleOut = join(publicDir, "apple-touch-icon.png"); +await sharp(appleSvg).resize(180, 180).png().toFile(appleOut); +console.log(`✓ ${appleOut}`); + +console.log("\nPWA icons generated."); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dbb8c48..25e975b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import { Geist } from "next/font/google"; import "./globals.css"; import GlobalUserBadge from "@/components/GlobalUserBadge"; +import ServiceWorkerRegistrar from "@/components/ServiceWorkerRegistrar"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -19,6 +20,8 @@ export const viewport: Viewport = { initialScale: 1, maximumScale: 1, userScalable: false, + viewportFit: "cover", + themeColor: "#10b981", }; const themeScript = `(function(){try{var t=localStorage.getItem("nowhatever-theme")||"system";var r=t;if(t==="system")r=window.matchMedia("(prefers-color-scheme:light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",r)}catch(e){}})()`; @@ -32,8 +35,10 @@ export default function RootLayout({