PWA basic implemented (not for admin.php)

This commit is contained in:
hbrain 2026-06-06 17:12:52 +02:00
parent d7da479741
commit acc77ac17f
12 changed files with 193 additions and 1 deletions

View file

@ -72,6 +72,7 @@ http_response_code(404);
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>MVLog - 404</title> <title>MVLog - 404</title>
<?php include __DIR__ . '/_pwa_head.php'; ?>
<!-- Basic Open Graph --> <!-- Basic Open Graph -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">

View file

@ -5,7 +5,7 @@
if(!btn || !('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return; if(!btn || !('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return;
function b64uToUint8Array(s){ const pad='='.repeat((4-s.length%4)%4); const b64=(s+pad).replace(/-/g,'+').replace(/_/g,'/'); const raw=atob(b64); return Uint8Array.from([...raw].map(c=>c.charCodeAt(0))); } function b64uToUint8Array(s){ const pad='='.repeat((4-s.length%4)%4); const b64=(s+pad).replace(/-/g,'+').replace(/_/g,'/'); const raw=atob(b64); return Uint8Array.from([...raw].map(c=>c.charCodeAt(0))); }
async function state(reg){ const sub=await reg.pushManager.getSubscription(); btn.classList.toggle('active', !!sub); btn.title=sub?'Notifications on':'Notifications'; btn.hidden=false; return sub; } async function state(reg){ const sub=await reg.pushManager.getSubscription(); btn.classList.toggle('active', !!sub); btn.title=sub?'Notifications on':'Notifications'; btn.hidden=false; return sub; }
navigator.serviceWorker.register('sw.js').then(async reg=>{ navigator.serviceWorker.register('/sw.js').then(async reg=>{
const cfg=await fetch('push_config.php',{cache:'no-store'}).then(r=>r.json()).catch(()=>({enabled:false})); const cfg=await fetch('push_config.php',{cache:'no-store'}).then(r=>r.json()).catch(()=>({enabled:false}));
if(!cfg.enabled || !cfg.publicKey) return; if(!cfg.enabled || !cfg.publicKey) return;
await state(reg); await state(reg);

8
_pwa_head.php Normal file
View file

@ -0,0 +1,8 @@
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1b1e22">
<meta name="application-name" content="MVLog">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MVLog">
<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
assets/img/pwa-icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
assets/img/pwa-icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

View file

@ -54,6 +54,7 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>MVL - Contact</title> <title>MVL - Contact</title>
<?php include __DIR__ . '/_pwa_head.php'; ?>
<meta property="og:title" content="MVLog: journal of an unreliable narrator."> <meta property="og:title" content="MVLog: journal of an unreliable narrator.">
<meta property="og:description" content="Motorcycle road stories, hockey passion, pizza nerdism and facts remembered by an unreliable narrator."> <meta property="og:description" content="Motorcycle road stories, hockey passion, pizza nerdism and facts remembered by an unreliable narrator.">
<meta property="og:image" content="https://bubulescu.org/assets/img/moto_travel.png"> <meta property="og:image" content="https://bubulescu.org/assets/img/moto_travel.png">

View file

@ -151,6 +151,7 @@ function render_blank_page(){
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>MVLog</title> <title>MVLog</title>
<?php include __DIR__ . '/_pwa_head.php'; ?>
<link rel="icon" type="image/png" href="assets/img/moto_travel.png"> <link rel="icon" type="image/png" href="assets/img/moto_travel.png">
<link rel="stylesheet" href="style.css?v=20260531u"> <link rel="stylesheet" href="style.css?v=20260531u">
@ -388,6 +389,7 @@ $siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title><?=h($pageTitle)?></title> <title><?=h($pageTitle)?></title>
<?php include __DIR__ . '/_pwa_head.php'; ?>
<!-- Basic Open Graph --> <!-- Basic Open Graph -->
<meta property="og:type" content="<?=h($ogType)?>"> <meta property="og:type" content="<?=h($ogType)?>">

40
manifest.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "MVLog - Bubulescu.Org",
"short_name": "MVLog",
"description": "Motorcycle road stories, hockey passion, pizza nerdism and facts remembered by an unreliable narrator.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": [
"standalone",
"minimal-ui"
],
"background_color": "#111315",
"theme_color": "#1b1e22",
"categories": [
"travel",
"photo",
"entertainment"
],
"icons": [
{
"src": "/assets/img/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/img/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/img/pwa-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

24
offline.html Normal file
View file

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>MVLog - Offline</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1b1e22">
<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">
<link rel="icon" type="image/png" href="/assets/img/moto_travel.png">
<link rel="stylesheet" href="/style.css">
<style>main.offline-page{min-height:calc(100vh - 8rem);display:grid;place-items:center;padding:7rem 1rem 3rem;text-align:center}.offline-card{max-width:34rem;background:rgba(27,30,34,.88);border:1px solid rgba(59,167,160,.35);border-radius:1rem;padding:2rem;box-shadow:0 2px 16px #0008}.offline-card img{width:6rem;height:6rem;object-fit:contain}.offline-card h1{margin:.8rem 0 .5rem}.offline-card p{color:#d4d7dd}</style>
</head>
<body>
<main class="offline-page">
<section class="offline-card">
<img src="/assets/img/moto_travel.png" alt="">
<h1>You&rsquo;re offline</h1>
<p>MVLog could not reach the network. Previously opened pages and assets may still be available.</p>
<p><a class="button" href="/">Try home again</a></p>
</section>
</main>
</body>
</html>

116
sw.js
View file

@ -1,3 +1,119 @@
const MVLOG_CACHE_PREFIX = 'mvlog-pwa-';
const MVLOG_CACHE_VERSION = 'mvlog-pwa-v1';
const APP_SHELL_CACHE = `${MVLOG_CACHE_VERSION}-app-shell`;
const RUNTIME_CACHE = `${MVLOG_CACHE_VERSION}-runtime`;
const APP_SHELL_URLS = [
'/',
'/offline.html',
'/style.css',
'/js/map_lightbox.js',
'/assets/fonts/BebasNeue-Regular.ttf',
'/assets/fonts/IBMPlexSans-SemiBold.ttf',
'/assets/fonts/NotoSans-Bold.ttf',
'/assets/img/background.jpg',
'/assets/img/moto_travel.png',
'/assets/img/pwa-icon-192.png',
'/assets/img/pwa-icon-512.png',
'/assets/img/pwa-maskable-512.png',
'/assets/img/apple-touch-icon.png',
'/manifest.json'
];
self.addEventListener('install', event => {
event.waitUntil((async () => {
const cache = await caches.open(APP_SHELL_CACHE);
await Promise.all(APP_SHELL_URLS.map(url =>
cache.add(new Request(url, { cache: 'reload' })).catch(() => undefined)
));
await self.skipWaiting();
})());
});
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(names.map(name => {
if (name.startsWith(MVLOG_CACHE_PREFIX) && ![APP_SHELL_CACHE, RUNTIME_CACHE].includes(name)) {
return caches.delete(name);
}
return undefined;
}));
await clients.claim();
})());
});
function shouldBypassFetch(request, url) {
if (request.method !== 'GET') return true;
if (!['http:', 'https:'].includes(url.protocol)) return true;
if (url.origin !== self.location.origin) return false;
const path = url.pathname;
if (path === '/admin.php' || path === '/login.php' || path === '/logout.php') return true;
if (path === '/push_config.php' || path === '/push_subscribe.php' || path === '/job_status.php') return true;
if (path.startsWith('/api/') || path.startsWith('/lib/') || path.startsWith('/cache/') || path.startsWith('/in-dir/')) return true;
if (path.startsWith('/out-dir/') && /\.(mp4|m4v|mov|webm)$/i.test(path)) return true;
if (/\.(mp4|m4v|mov|webm)$/i.test(path)) return true;
return false;
}
function isStaticAsset(url) {
if (url.origin !== self.location.origin) return false;
const path = url.pathname;
return path === '/style.css'
|| path === '/manifest.json'
|| path === '/offline.html'
|| path.startsWith('/assets/')
|| path.startsWith('/js/');
}
async function networkFirstNavigation(request) {
try {
const response = await fetch(request);
if (response && response.ok) {
const cache = await caches.open(RUNTIME_CACHE);
cache.put(request, response.clone()).catch(() => undefined);
}
return response;
} catch (error) {
const exact = await caches.match(request);
if (exact) return exact;
const offline = await caches.match('/offline.html');
if (offline) return offline;
const home = await caches.match('/');
if (home) return home;
return new Response('Offline', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
}
async function staleWhileRevalidate(request) {
const cached = await caches.match(request);
const cache = await caches.open(RUNTIME_CACHE);
const network = fetch(request).then(response => {
if (response && response.ok) {
cache.put(request, response.clone()).catch(() => undefined);
}
return response;
}).catch(() => cached);
return cached || network;
}
self.addEventListener('fetch', event => {
const request = event.request;
const url = new URL(request.url);
if (shouldBypassFetch(request, url)) return;
if (request.mode === 'navigate') {
event.respondWith(networkFirstNavigation(request));
return;
}
if (isStaticAsset(url)) {
event.respondWith(staleWhileRevalidate(request));
}
});
self.addEventListener('push', event => { self.addEventListener('push', event => {
let data = {}; let data = {};
try { data = event.data ? event.data.json() : {}; } catch (e) { data = { title: 'MVLog', body: event.data ? event.data.text() : 'New article @ MVLog!' }; } try { data = event.data ? event.data.json() : {}; } catch (e) { data = { title: 'MVLog', body: event.data ? event.data.text() : 'New article @ MVLog!' }; }