PWA
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,104 @@
|
||||
(() => {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const toast = document.querySelector('[data-pwa-toast]');
|
||||
const title = toast?.querySelector('[data-pwa-title]');
|
||||
const message = toast?.querySelector('[data-pwa-message]');
|
||||
const primary = toast?.querySelector('[data-pwa-primary]');
|
||||
const close = toast?.querySelector('[data-pwa-close]');
|
||||
let installPrompt = null;
|
||||
let waitingWorker = null;
|
||||
let toastMode = '';
|
||||
let refreshing = false;
|
||||
|
||||
const showToast = (mode, heading, detail, actionLabel) => {
|
||||
if (!toast || !title || !message || !primary) return;
|
||||
toastMode = mode;
|
||||
title.textContent = heading;
|
||||
message.textContent = detail;
|
||||
primary.textContent = actionLabel;
|
||||
primary.hidden = !actionLabel;
|
||||
toast.hidden = false;
|
||||
};
|
||||
|
||||
const hideToast = () => {
|
||||
if (toast) toast.hidden = true;
|
||||
};
|
||||
|
||||
const installDismissedRecently = () => {
|
||||
try {
|
||||
const dismissedAt = Number(localStorage.getItem('pwa-install-dismissed-at'));
|
||||
return dismissedAt > 0 && Date.now() - dismissedAt < 30 * 86400000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
event.preventDefault();
|
||||
installPrompt = event;
|
||||
if (!installDismissedRecently()) {
|
||||
window.setTimeout(() => {
|
||||
if (installPrompt) showToast('install', '安装 Bi\'s Blog', '添加到桌面,像应用一样打开并阅读缓存过的文章。', '立即安装');
|
||||
}, 2500);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installPrompt = null;
|
||||
showToast('ready', '安装完成', '博客已经添加到你的设备。', '知道了');
|
||||
});
|
||||
|
||||
primary?.addEventListener('click', async () => {
|
||||
if (toastMode === 'install' && installPrompt) {
|
||||
await installPrompt.prompt();
|
||||
await installPrompt.userChoice;
|
||||
installPrompt = null;
|
||||
hideToast();
|
||||
return;
|
||||
}
|
||||
|
||||
if (toastMode === 'update' && waitingWorker) {
|
||||
refreshing = true;
|
||||
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||
return;
|
||||
}
|
||||
|
||||
hideToast();
|
||||
});
|
||||
|
||||
close?.addEventListener('click', () => {
|
||||
if (toastMode === 'install') {
|
||||
try { localStorage.setItem('pwa-install-dismissed-at', String(Date.now())); } catch {}
|
||||
}
|
||||
hideToast();
|
||||
});
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (refreshing) window.location.reload();
|
||||
});
|
||||
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).then((registration) => {
|
||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
||||
waitingWorker = registration.waiting;
|
||||
showToast('update', '发现新版本', '刷新后即可使用博客的最新内容和功能。', '立即更新');
|
||||
}
|
||||
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const worker = registration.installing;
|
||||
worker?.addEventListener('statechange', () => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
waitingWorker = worker;
|
||||
showToast('update', '发现新版本', '刷新后即可使用博客的最新内容和功能。', '立即更新');
|
||||
}
|
||||
});
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.warn('PWA registration failed:', error);
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('action') === 'search') {
|
||||
window.setTimeout(() => document.querySelector('.search-typesense-trigger')?.click(), 350);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Bi's Blog",
|
||||
"short_name": "Bi's Blog",
|
||||
"description": "好奇心转化为代码的空间。",
|
||||
"lang": "zh-CN",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||
"theme_color": "#0f766e",
|
||||
"background_color": "#f7f5ef",
|
||||
"categories": ["blog", "education", "technology"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/pwa-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/images/pwa-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/images/pwa-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "搜索文章",
|
||||
"short_name": "搜索",
|
||||
"url": "/?action=search",
|
||||
"icons": [{ "src": "/images/pwa-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "随机文章",
|
||||
"short_name": "随机文章",
|
||||
"url": "/go/",
|
||||
"icons": [{ "src": "/images/pwa-192x192.png", "sizes": "192x192" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f766e" />
|
||||
<title>暂时离线 | Bi's Blog</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; color: #27313a; font-family: system-ui, "Microsoft YaHei", sans-serif; background: linear-gradient(145deg, #f7f5ef, #e5f5ef); }
|
||||
main { width: min(520px, 100%); border: 1px solid rgba(255,255,255,.8); border-radius: 18px; padding: 40px; text-align: center; background: rgba(255,253,248,.82); box-shadow: 0 24px 70px rgba(44,55,63,.16); }
|
||||
.icon { display: grid; width: 72px; height: 72px; margin: 0 auto 22px; place-items: center; border-radius: 20px; color: white; font-size: 30px; font-weight: 700; background: #0f766e; }
|
||||
h1 { margin: 0; font-size: clamp(1.8rem, 6vw, 2.6rem); }
|
||||
p { margin: 14px 0 26px; color: #686f78; line-height: 1.8; }
|
||||
a, button { display: inline-flex; min-height: 42px; align-items: center; justify-content: center; border: 0; border-radius: 999px; padding: 9px 18px; color: white; font: inherit; font-weight: 700; text-decoration: none; background: #0f766e; cursor: pointer; }
|
||||
a { margin-left: 8px; color: #0f766e; background: #dff5ed; }
|
||||
@media (prefers-color-scheme: dark) { body { color: #eef4f6; background: #0b1014; } main { border-color: rgba(181,198,207,.2); background: #141b21; } p { color: #b6c3c9; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="icon" aria-hidden="true">Bi</div>
|
||||
<h1>网络暂时走丢了</h1>
|
||||
<p>已经读过的文章仍可从浏览器历史或返回按钮打开。网络恢复后,再来继续探索吧。</p>
|
||||
<button type="button" onclick="location.reload()">重新连接</button>
|
||||
<a href="/">返回首页</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
const CACHE_VERSION = '2026-07-17-v1';
|
||||
const CACHE_PREFIX = 'bis-blog-';
|
||||
const CORE_CACHE = `${CACHE_PREFIX}core-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `${CACHE_PREFIX}pages-${CACHE_VERSION}`;
|
||||
const ASSET_CACHE = `${CACHE_PREFIX}assets-${CACHE_VERSION}`;
|
||||
const IMAGE_CACHE = `${CACHE_PREFIX}images-${CACHE_VERSION}`;
|
||||
|
||||
const CORE_ASSETS = [
|
||||
'/',
|
||||
'/offline.html',
|
||||
'/manifest.webmanifest',
|
||||
'/images/Bi.ico',
|
||||
'/images/pwa-192x192.png',
|
||||
'/images/pwa-512x512.png',
|
||||
'/images/pwa-maskable-512x512.png',
|
||||
'/images/apple-touch-icon.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CORE_CACHE)
|
||||
.then((cache) => cache.addAll(CORE_ASSETS))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name.startsWith(CACHE_PREFIX) && ![CORE_CACHE, PAGE_CACHE, ASSET_CACHE, IMAGE_CACHE].includes(name))
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
await self.clients.claim();
|
||||
const clients = await self.clients.matchAll({ type: 'window' });
|
||||
clients.forEach((client) => client.postMessage({ type: 'OFFLINE_READY' }));
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
|
||||
});
|
||||
|
||||
async function trimCache(cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
if (keys.length > maxEntries) {
|
||||
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(PAGE_CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
void trimCache(PAGE_CACHE, 40);
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return (await caches.match(request)) || (await caches.match('/offline.html'));
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheFirst(request, cacheName, maxEntries) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await fetch(request);
|
||||
if (response.ok || response.type === 'opaque') {
|
||||
const cache = await caches.open(cacheName);
|
||||
await cache.put(request, response.clone());
|
||||
void trimCache(cacheName, maxEntries);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function staleWhileRevalidate(request) {
|
||||
const cache = await caches.open(ASSET_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
const network = fetch(request).then((response) => {
|
||||
if (response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
}).catch(() => cached);
|
||||
return cached || network;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.destination === 'image') {
|
||||
event.respondWith(cacheFirst(request, IMAGE_CACHE, 100));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
url.origin === self.location.origin &&
|
||||
['style', 'script', 'font', 'worker'].includes(request.destination)
|
||||
) {
|
||||
event.respondWith(staleWhileRevalidate(request));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<aside class="pwa-toast" data-pwa-toast hidden role="status" aria-live="polite">
|
||||
<div class="pwa-toast-icon" aria-hidden="true"><i class="fa-solid fa-download"></i></div>
|
||||
<div class="pwa-toast-copy">
|
||||
<strong data-pwa-title>PWA 提示</strong>
|
||||
<p data-pwa-message></p>
|
||||
</div>
|
||||
<div class="pwa-toast-actions">
|
||||
<button class="pwa-toast-primary" type="button" data-pwa-primary>确定</button>
|
||||
<button class="pwa-toast-close" type="button" data-pwa-close aria-label="关闭提示">稍后</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -2,6 +2,7 @@
|
||||
import '../styles/site.css';
|
||||
import CustomContextMenu from '@/components/CustomContextMenu.astro';
|
||||
import HitokotoCard from '@/components/HitokotoCard.astro';
|
||||
import PwaPrompt from '@/components/PwaPrompt.astro';
|
||||
import { publicSiteConfig, siteConfig } from '../../site.config.mjs';
|
||||
|
||||
interface Props {
|
||||
@@ -90,6 +91,10 @@ const consoleSites = [
|
||||
{keywords.map((keyword) => <meta property="article:tag" content={keyword} />)}
|
||||
{jsonLd && <script type="application/ld+json" is:inline set:html={jsonLdHtml}></script>}
|
||||
<link rel="icon" href={siteConfig.assets.icon} />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#f7f5ef" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0b1014" media="(prefers-color-scheme: dark)" />
|
||||
<script defer src={siteConfig.analytics.umami.script} data-website-id={siteConfig.analytics.umami.websiteId}></script>
|
||||
<script is:inline set:html={`window.SITE_CONFIG = ${publicConfigJson};`}></script>
|
||||
<script is:inline>
|
||||
@@ -243,12 +248,14 @@ const consoleSites = [
|
||||
</footer>
|
||||
<HitokotoCard />
|
||||
<CustomContextMenu />
|
||||
<PwaPrompt />
|
||||
<script is:inline src={siteConfig.cdn.instantsearchJs}></script>
|
||||
<script is:inline src={siteConfig.cdn.typesenseAdapterJs}></script>
|
||||
<script is:inline src={siteConfig.cdn.fancyboxJs}></script>
|
||||
<script is:inline src="/js/random.js"></script>
|
||||
<script is:inline src="/js/search/typesense-search.js"></script>
|
||||
<script is:inline src="/js/external-links.js"></script>
|
||||
<script is:inline src="/js/pwa.js"></script>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const nav = document.querySelector('.nav');
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
@import "./site/13-responsive.css";
|
||||
@import "./site/14-animations.css";
|
||||
@import "./site/15-reading-series.css";
|
||||
@import "./site/16-pwa.css";
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
.pwa-toast {
|
||||
position: fixed;
|
||||
right: max(18px, var(--page-gutter));
|
||||
bottom: 24px;
|
||||
z-index: 34;
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
border: 1px solid rgba(255, 255, 255, 0.68);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
color: #27313a;
|
||||
background: rgba(255, 253, 248, 0.94);
|
||||
box-shadow: 0 22px 60px rgba(44, 55, 63, 0.24);
|
||||
backdrop-filter: blur(20px) saturate(165%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(165%);
|
||||
animation: pwa-toast-in 0.3s ease both;
|
||||
}
|
||||
|
||||
.pwa-toast[hidden] { display: none; }
|
||||
.pwa-toast-icon { display: grid; width: 44px; height: 44px; place-items: center; border-radius: 12px; color: #fff; background: #3eb8be; }
|
||||
.pwa-toast-copy { min-width: 0; }
|
||||
.pwa-toast-copy strong, .pwa-toast-copy p { margin: 0; }
|
||||
.pwa-toast-copy p { color: var(--muted); font-size: 0.88rem; line-height: 1.5; }
|
||||
.pwa-toast-actions { display: flex; gap: 7px; }
|
||||
.pwa-toast button { border: 1px solid rgba(15, 118, 110, 0.18); border-radius: 999px; padding: 7px 12px; color: var(--accent); font: inherit; font-size: 0.84rem; font-weight: 800; background: rgba(236, 253, 245, 0.72); cursor: pointer; }
|
||||
.pwa-toast .pwa-toast-primary { color: #fff; background: #3eb8be; }
|
||||
|
||||
:root[data-theme='dark'] .pwa-toast { border-color: rgba(139, 224, 213, 0.22); color: #edf7f5; background: rgba(13, 23, 29, 0.94); }
|
||||
|
||||
@keyframes pwa-toast-in {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.pwa-toast { right: 16px; bottom: 16px; grid-template-columns: 40px minmax(0, 1fr); }
|
||||
.pwa-toast-icon { width: 40px; height: 40px; }
|
||||
.pwa-toast-actions { grid-column: 1 / -1; }
|
||||
.pwa-toast button { flex: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pwa-toast { animation: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user