diff --git a/public/boot.js b/public/boot.js index e9983df..c390238 100644 --- a/public/boot.js +++ b/public/boot.js @@ -29,7 +29,15 @@ let lastBadge = Date.now(); badgePoll(); setInterval(() => { if (document.visibilityState === 'visible') { lastBadge = Date.now(); badgePoll(); } }, 2 * 60e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Date.now() - lastBadge > 30e3) { lastBadge = Date.now(); badgePoll(); } }); - if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {}); + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + // tell the service worker who is signed in, so what it saves for offline use is kept for that person only + const who = () => navigator.serviceWorker.ready.then(r => (r.active || navigator.serviceWorker.controller || {}).postMessage && (r.active || navigator.serviceWorker.controller).postMessage({ type: 'who', id: ME ? ME.id : '' })).catch(() => {}); + who(); navigator.serviceWorker.addEventListener('controllerchange', who); + } + // offline: say so once, and that what is on screen is the last saved copy + { const note = () => { const el = document.getElementById('offnote') || Object.assign(document.createElement('div'), { id: 'offnote', role: 'status' }); el.textContent = 'You are offline. Showing what was last saved; changes need a connection.'; el.hidden = navigator.onLine; if (!el.isConnected) document.body.appendChild(el); }; + addEventListener('offline', note); addEventListener('online', note); if (!navigator.onLine) note(); } })(); // Keyboard (desktop): space play/pause, arrows seek, N/P next/previous. diff --git a/public/index.html b/public/index.html index 5323735..3a498b9 100644 --- a/public/index.html +++ b/public/index.html @@ -52,62 +52,62 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/style.css b/public/style.css index 9731d0b..085b230 100644 --- a/public/style.css +++ b/public/style.css @@ -1103,3 +1103,6 @@ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simpleho .lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); } .invlist { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 6px 0; } .invdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin: 0 6px 0 2px; } .vnrow { gap: 10px; } .vnart { width: 44px; flex: none; aspect-ratio: 2/3; border-radius: 6px; overflow: hidden; background: var(--panel2); display: grid; place-items: center; } .vnart img { width: 100%; height: 100%; object-fit: cover; } .vnvote { min-height: 36px; padding: 0 12px; flex: none; } +html.rm *, html.rm *::before, html.rm *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } +html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-decoration: underline; } html.hc .btn.ghost, html.hc select, html.hc input[type=text], html.hc input[type=search], html.hc textarea { border: 2px solid var(--line); } html.hc :focus-visible { outline-width: 3px; } +#offnote { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--sab) + 70px); z-index: 90; max-width: min(92vw, 460px); padding: 10px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0, 0, 0, .3); font-size: 14px; text-align: center; } #offnote[hidden] { display: none; } diff --git a/public/sw.js b/public/sw.js index 8f16b5e..a0bd3f2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,6 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v108'; +const CACHE = 'hub-v111'; +const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; @@ -19,7 +20,7 @@ async function precache() { } self.addEventListener('install', e => { e.waitUntil(precache()); self.skipWaiting(); }); self.addEventListener('activate', e => { - e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k))))); + e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE && !KEEP.test(k)).map(k => caches.delete(k))))); // (the app shell is replaced on a new version; saved episodes, shares and each person's saved data stay) self.clients.claim(); }); // Podcast episodes saved on this device (see offline.js): answer from them, including the byte ranges an audio player asks for; otherwise use the network as normal. @@ -32,6 +33,27 @@ async function offlineAudio(req) { if (a > b || a >= size) return new Response(null, { status: 416, headers: { 'content-range': 'bytes */' + size } }); return new Response(blob.slice(a, b + 1), { status: 206, headers: { 'content-type': type, 'content-length': b - a + 1, 'content-range': `bytes ${a}-${b}/${size}`, 'accept-ranges': 'bytes' } }); } +// Saved data for offline use: the lists a person is likely to want with no signal (tasks, notes, recipes, calendar, contacts, chores). Network first; when the network fails the last +// copy is used. Kept per person (the page tells us who is signed in), so a shared tablet never shows one person's data to another. Reading only: changes need a connection. +const DATA_OK = /^\/api\/(tasks(\/(upcoming|summary|lists|due))?|notes|recipes(\/(r\/[\w-]+|shopping))?|cal\/(events|list)|contacts|chores(\/\w+)?|bible\/(state))\/?$/; +const whoNow = async () => { try { const r = await (await caches.open('hub-who')).match('/who'); return r ? await r.text() : ''; } catch { return ''; } }; +self.addEventListener('message', e => { + const d = e.data || {}; if (d.type !== 'who') return; + const id = String(d.id || '').replace(/[^\w-]/g, ''); + e.waitUntil((async () => { await (await caches.open('hub-who')).put('/who', new Response(id)); for (const k of await caches.keys()) if (k.startsWith('hub-data-') && k !== 'hub-data-' + id) await caches.delete(k); })()); +}); +async function dataFirstNetwork(req) { + const who = await whoNow(); + try { + const r = await fetch(req); + if (r.ok && who) { const copy = r.clone(); caches.open('hub-data-' + who).then(c => c.put(req.url, copy)); } + return r; + } catch (err) { + const hit = who && await (await caches.open('hub-data-' + who)).match(req.url); + if (hit) return hit; + throw err; + } +} // Share target: other apps send files here (POST); keep them and open the app, which asks where to put them. async function takeShare(request) { try { @@ -47,6 +69,7 @@ self.addEventListener('fetch', e => { if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request)); if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/podcast\/podcasts\/[^/]+\/file$/.test(u.pathname)) return e.respondWith(offlineAudio(e.request)); if (e.request.method === 'GET' && u.origin === location.origin && /^\/plex\/library\/parts\//.test(u.pathname)) return e.respondWith(offlineAudio(e.request)); // (albums saved on this device) + if (e.request.method === 'GET' && u.origin === location.origin && DATA_OK.test(u.pathname)) return e.respondWith(dataFirstNetwork(e.request)); if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return; e.respondWith(fetch(e.request).then(r => { if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); } diff --git a/public/themes.js b/public/themes.js index 6399202..79c9ba8 100644 --- a/public/themes.js +++ b/public/themes.js @@ -15,6 +15,11 @@ function themeApply() { const hex = dark ? acc[3] : acc[2]; if (acc[0] === 'blue' && mode === 'auto') { root.style.removeProperty('--accent'); root.style.removeProperty('--accent-t'); } else { const n = parseInt(hex.slice(1), 16), lum = (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; root.style.setProperty('--accent', hex); root.style.setProperty('--accent-t', lum > .6 ? '#0b1020' : '#fff'); } + const hc = !!store.get('a11y_contrast', false), size = +store.get('a11y_text', 1) || 1; + const HC = dark ? { '--text': '#ffffff', '--muted': '#d3d8e2', '--line': '#7a8294' } : { '--text': '#000000', '--muted': '#33383f', '--line': '#6c7482' }; + for (const k of Object.keys(HC)) { if (hc) root.style.setProperty(k, HC[k]); else if (mode === 'auto') root.style.removeProperty(k); else root.style.setProperty(k, THEME_VARS[mode][k]); } + root.classList.toggle('hc', hc); root.classList.toggle('rm', !!store.get('a11y_motion', false)); + const view = document.getElementById('view'); if (view) view.style.zoom = size === 1 ? '' : String(size); // larger text and controls for the page itself root.style.colorScheme = mode === 'auto' ? '' : dark ? 'dark' : 'light'; const m = document.querySelector('meta[name=theme-color]'); if (m) m.content = dark ? (mode === 'amoled' ? '#000000' : '#0f1115') : (mode === 'light' ? '#f5f6f8' : m.content); } @@ -23,10 +28,17 @@ function themeSettings(el) { if (!el) return; const draw = () => { const mode = store.get('theme_mode', 'auto'), acc = store.get('theme_accent', 'blue'); - el.innerHTML = `

Look

Saved for you and used on every device you sign in on.

+ el.innerHTML = `

Look and reading

Saved for you and used on every device you sign in on.

${THEME_MODES.map(([k, l]) => `${l}`).join('')}
${THEME_ACCENTS.map(([k, l, c]) => ``).join('')}
+

Reading

+
${[[1, 'Normal'], [1.15, 'Large'], [1.3, 'Extra large']].map(([v, l]) => `${l}`).join('')}
+
+

My colour on shared calendars

${(typeof ME !== 'undefined' && ME ? THEME_PERSON : []).map(c => ``).join('')}
`; + el.querySelectorAll('[data-ts]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('a11y_text', +a.dataset.ts); themeApply(); draw(); }); + el.querySelector('#a11yhc').onchange = e => { store.set('a11y_contrast', e.target.checked); themeApply(); }; + el.querySelector('#a11yrm').onchange = e => { store.set('a11y_motion', e.target.checked); themeApply(); }; el.querySelectorAll('[data-pc]').forEach(b => b.onclick = async () => { try { const r = await jfetch('/api/me/color', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ color: b.dataset.pc }) }); ME.color = r.color; try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} draw(); } catch (e) { toast(e.message); } }); el.querySelectorAll('[data-m]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('theme_mode', a.dataset.m); themeApply(); draw(); }); el.querySelectorAll('[data-a]').forEach(b => b.onclick = () => { store.set('theme_accent', b.dataset.a); themeApply(); draw(); });