Accessibility settings, deferred scripts, and offline reading of tasks, notes, recipes, calendar and contacts
Keeps saved episodes and shares when the app updates (they were being deleted with the old shell cache). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2766424bb2
commit
54d18594a3
5 files changed
+107
-61
No files matched your search
+9
-1
@@ -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.
|
||||
|
||||
+57
-57
@@ -52,62 +52,62 @@
|
||||
<div id="main"><div id="navbar" hidden></div><div id="subnav" class="seg" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
|
||||
</div>
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="/webauthn.js"></script>
|
||||
<script src="/links.js"></script>
|
||||
<script src="/palette.js"></script>
|
||||
<script src="/planner.js"></script>
|
||||
<script src="/reminders.js"></script>
|
||||
<script src="/file-history.js"></script>
|
||||
<script src="/music-extras.js"></script>
|
||||
<script src="/music-more.js"></script>
|
||||
<script src="/screentime-view.js"></script>
|
||||
<script src="/calendar-views.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/search-view.js"></script>
|
||||
<script src="/today.js"></script>
|
||||
<script src="/calendar.js"></script>
|
||||
<script src="/tasks.js"></script>
|
||||
<script src="/audiobooks.js"></script>
|
||||
<script src="/music.js"></script>
|
||||
<script src="/video.js"></script>
|
||||
<script src="/office.js"></script>
|
||||
<script src="/player.js"></script>
|
||||
<script src="/offline.js"></script>
|
||||
<script src="/podcast-view.js"></script>
|
||||
<script src="/reader.js"></script>
|
||||
<script src="/audiobooks-view.js"></script>
|
||||
<script src="/librivox-view.js"></script>
|
||||
<script src="/radio-view.js"></script>
|
||||
<script src="/store-view.js"></script>
|
||||
<script src="/recipes-view.js"></script>
|
||||
<script src="/contacts-view.js"></script>
|
||||
<script src="/bible-view.js"></script>
|
||||
<script src="/devotion-view.js"></script>
|
||||
<script src="/save-to-files.js"></script>
|
||||
<script src="/vbv-view.js"></script>
|
||||
<script src="/lyrics-view.js"></script>
|
||||
<script src="/visualizer.js"></script>
|
||||
<script src="/settings.js"></script>
|
||||
<script src="/files.js"></script>
|
||||
<script src="/files-extra.js"></script>
|
||||
<script src="/sheets.js"></script>
|
||||
<script src="/file-actions.js"></script>
|
||||
<script src="/signal-view.js"></script>
|
||||
<script src="/chat-view.js"></script>
|
||||
<script src="/notes-view.js"></script>
|
||||
<script src="/chores-view.js"></script>
|
||||
<script src="/family-view.js"></script>
|
||||
<script src="/mail.js"></script>
|
||||
<script src="/share.js"></script>
|
||||
<script src="/cast.js"></script>
|
||||
<script src="/rows.js"></script>
|
||||
<script src="/keys.js"></script>
|
||||
<script src="/inbox-view.js"></script>
|
||||
<script src="/device-ui.js"></script>
|
||||
<script src="/settings-family.js"></script>
|
||||
<script src="/simple-mode.js"></script>
|
||||
<script src="/themes.js"></script>
|
||||
<script src="/boot.js"></script>
|
||||
<script src="/shell.js"></script>
|
||||
<script defer src="/webauthn.js"></script>
|
||||
<script defer src="/links.js"></script>
|
||||
<script defer src="/palette.js"></script>
|
||||
<script defer src="/planner.js"></script>
|
||||
<script defer src="/reminders.js"></script>
|
||||
<script defer src="/file-history.js"></script>
|
||||
<script defer src="/music-extras.js"></script>
|
||||
<script defer src="/music-more.js"></script>
|
||||
<script defer src="/screentime-view.js"></script>
|
||||
<script defer src="/calendar-views.js"></script>
|
||||
<script defer src="/app.js"></script>
|
||||
<script defer src="/search-view.js"></script>
|
||||
<script defer src="/today.js"></script>
|
||||
<script defer src="/calendar.js"></script>
|
||||
<script defer src="/tasks.js"></script>
|
||||
<script defer src="/audiobooks.js"></script>
|
||||
<script defer src="/music.js"></script>
|
||||
<script defer src="/video.js"></script>
|
||||
<script defer src="/office.js"></script>
|
||||
<script defer src="/player.js"></script>
|
||||
<script defer src="/offline.js"></script>
|
||||
<script defer src="/podcast-view.js"></script>
|
||||
<script defer src="/reader.js"></script>
|
||||
<script defer src="/audiobooks-view.js"></script>
|
||||
<script defer src="/librivox-view.js"></script>
|
||||
<script defer src="/radio-view.js"></script>
|
||||
<script defer src="/store-view.js"></script>
|
||||
<script defer src="/recipes-view.js"></script>
|
||||
<script defer src="/contacts-view.js"></script>
|
||||
<script defer src="/bible-view.js"></script>
|
||||
<script defer src="/devotion-view.js"></script>
|
||||
<script defer src="/save-to-files.js"></script>
|
||||
<script defer src="/vbv-view.js"></script>
|
||||
<script defer src="/lyrics-view.js"></script>
|
||||
<script defer src="/visualizer.js"></script>
|
||||
<script defer src="/settings.js"></script>
|
||||
<script defer src="/files.js"></script>
|
||||
<script defer src="/files-extra.js"></script>
|
||||
<script defer src="/sheets.js"></script>
|
||||
<script defer src="/file-actions.js"></script>
|
||||
<script defer src="/signal-view.js"></script>
|
||||
<script defer src="/chat-view.js"></script>
|
||||
<script defer src="/notes-view.js"></script>
|
||||
<script defer src="/chores-view.js"></script>
|
||||
<script defer src="/family-view.js"></script>
|
||||
<script defer src="/mail.js"></script>
|
||||
<script defer src="/share.js"></script>
|
||||
<script defer src="/cast.js"></script>
|
||||
<script defer src="/rows.js"></script>
|
||||
<script defer src="/keys.js"></script>
|
||||
<script defer src="/inbox-view.js"></script>
|
||||
<script defer src="/device-ui.js"></script>
|
||||
<script defer src="/settings-family.js"></script>
|
||||
<script defer src="/simple-mode.js"></script>
|
||||
<script defer src="/themes.js"></script>
|
||||
<script defer src="/boot.js"></script>
|
||||
<script defer src="/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
+25
-2
@@ -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)); }
|
||||
|
||||
+13
-1
@@ -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 = `<h2>Look</h2><p class="sub">Saved for you and used on every device you sign in on.</p>
|
||||
el.innerHTML = `<h2>Look and reading</h2><p class="sub">Saved for you and used on every device you sign in on.</p>
|
||||
<div class="seg" role="radiogroup" aria-label="Brightness">${THEME_MODES.map(([k, l]) => `<a href="#" role="radio" aria-checked="${mode === k}" data-m="${k}" class="${mode === k ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||||
<div class="swatches" role="radiogroup" aria-label="Accent colour">${THEME_ACCENTS.map(([k, l, c]) => `<button type="button" role="radio" aria-checked="${acc === k}" aria-label="${l}" title="${l}" data-a="${k}" class="${acc === k ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>
|
||||
<h3 class="sub" style="margin:14px 0 0">Reading</h3>
|
||||
<div class="seg" role="radiogroup" aria-label="Text size">${[[1, 'Normal'], [1.15, 'Large'], [1.3, 'Extra large']].map(([v, l]) => `<a href="#" role="radio" aria-checked="${+store.get('a11y_text', 1) === v}" data-ts="${v}" class="${+store.get('a11y_text', 1) === v ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||||
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>High contrast</b><small>Stronger text and borders</small></span><input type="checkbox" id="a11yhc"${store.get('a11y_contrast', false) ? ' checked' : ''}></label>
|
||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Reduce motion</b><small>Fewer animations, whatever the device says</small></span><input type="checkbox" id="a11yrm"${store.get('a11y_motion', false) ? ' checked' : ''}></label></div>
|
||||
<h3 class="sub" style="margin:14px 0 0">My colour on shared calendars</h3><div class="swatches" role="radiogroup" aria-label="My colour on shared calendars">${(typeof ME !== 'undefined' && ME ? THEME_PERSON : []).map(c => `<button type="button" role="radio" aria-checked="${ME.color === c}" aria-label="${c}" data-pc="${c}" class="${ME.color === c ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>`;
|
||||
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(); });
|
||||
|
||||
Reference in new issue
Block a user