feat: 添加 PWA 支持 — 可安装到主屏幕、离线缓存、刘海屏适配
This commit is contained in:
+4
-4
@@ -33,10 +33,10 @@
|
|||||||
- 抽中后打卡确认(拍照上传,形成回忆)
|
- 抽中后打卡确认(拍照上传,形成回忆)
|
||||||
- "本周契约执行率" 统计
|
- "本周契约执行率" 统计
|
||||||
|
|
||||||
### PWA 支持
|
### ~~PWA 支持~~(已完成)
|
||||||
- 添加 Web App Manifest,支持"添加到主屏幕"
|
- ~~添加 Web App Manifest,支持"添加到主屏幕"~~
|
||||||
- Service Worker 离线缓存基础页面
|
- ~~Service Worker 离线缓存基础页面~~
|
||||||
- `viewport-fit=cover` 适配刘海屏
|
- ~~`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 { Geist } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import GlobalUserBadge from "@/components/GlobalUserBadge";
|
import GlobalUserBadge from "@/components/GlobalUserBadge";
|
||||||
|
import ServiceWorkerRegistrar from "@/components/ServiceWorkerRegistrar";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -19,6 +20,8 @@ export const viewport: Viewport = {
|
|||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 1,
|
||||||
userScalable: false,
|
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){}})()`;
|
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>
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
</head>
|
</head>
|
||||||
<body className={`${geistSans.variable} font-sans antialiased`}>
|
<body className={`${geistSans.variable} font-sans antialiased`}>
|
||||||
|
<ServiceWorkerRegistrar />
|
||||||
<GlobalUserBadge />
|
<GlobalUserBadge />
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</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