feat: 添加 PWA 支持 — 可安装到主屏幕、离线缓存、刘海屏适配
This commit is contained in:
+4
-4
@@ -33,10 +33,10 @@
|
||||
- 抽中后打卡确认(拍照上传,形成回忆)
|
||||
- "本周契约执行率" 统计
|
||||
|
||||
### PWA 支持
|
||||
- 添加 Web App Manifest,支持"添加到主屏幕"
|
||||
- Service Worker 离线缓存基础页面
|
||||
- `viewport-fit=cover` 适配刘海屏
|
||||
### ~~PWA 支持~~(已完成)
|
||||
- ~~添加 Web App Manifest,支持"添加到主屏幕"~~
|
||||
- ~~Service Worker 离线缓存基础页面~~
|
||||
- ~~`viewport-fit=cover` 适配刘海屏~~
|
||||
|
||||
### 盲盒房间删除 / 退出
|
||||
- 房间创建者可删除房间(级联清理成员 & 想法)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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")))
|
||||
);
|
||||
});
|
||||
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="${radius}" fill="${BG_DARK}"/>
|
||||
<text x="50%" y="54%" text-anchor="middle" dominant-baseline="central"
|
||||
font-family="system-ui,-apple-system,sans-serif" font-weight="700"
|
||||
font-size="${fontSize}" fill="${ACCENT}">NW</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
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.");
|
||||
@@ -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({
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
</head>
|
||||
<body className={`${geistSans.variable} font-sans antialiased`}>
|
||||
<ServiceWorkerRegistrar />
|
||||
<GlobalUserBadge />
|
||||
{children}
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "NoWhatever — 别说随便",
|
||||
short_name: "NoWhatever",
|
||||
description: "像 Tinder 一样滑卡片,和朋友一起决定去哪吃!",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#030712",
|
||||
theme_color: "#10b981",
|
||||
orientation: "portrait",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icon-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icon-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { WifiOff } from "lucide-react";
|
||||
|
||||
export default function OfflinePage() {
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col items-center justify-center px-6 bg-background text-foreground">
|
||||
<div className="w-16 h-16 rounded-2xl bg-surface flex items-center justify-center mb-6">
|
||||
<WifiOff className="w-8 h-8 text-muted" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-heading mb-2">没有网络连接</h1>
|
||||
<p className="text-secondary text-center mb-8">
|
||||
请检查你的网络设置,然后重试
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-6 py-3 bg-accent text-white rounded-xl font-medium active:scale-95 transition-transform"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ServiceWorkerRegistrar() {
|
||||
useEffect(() => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user