diff --git a/public/index.html b/public/index.html index 4fcb28d..68dca8d 100644 --- a/public/index.html +++ b/public/index.html @@ -56,5 +56,6 @@ + diff --git a/public/shell.js b/public/shell.js new file mode 100644 index 0000000..813d3bc --- /dev/null +++ b/public/shell.js @@ -0,0 +1,56 @@ +// App-shell niceties that do not belong to any one tab: offline notice and an accessibility layer +// (names for icon-only buttons, a skip link, keyboard handling and focus for sheets). +(function () { + // ---- Offline notice + const bar = document.createElement('div'); + bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true; + bar.textContent = 'You are offline. Showing what was saved on this device; changes will not sync until you are back online.'; + document.body.appendChild(bar); + const sync = () => { bar.hidden = navigator.onLine; }; + addEventListener('online', sync); addEventListener('offline', sync); sync(); + + // ---- Skip link and landmarks + const skip = document.createElement('a'); + skip.href = '#view'; skip.className = 'skip'; skip.textContent = 'Skip to content'; + skip.addEventListener('click', e => { e.preventDefault(); const v = document.getElementById('view'); if (v) { v.setAttribute('tabindex', '-1'); v.focus(); } }); + document.body.insertBefore(skip, document.body.firstChild); + const nav = document.getElementById('nav'); if (nav && !nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Main'); + + // ---- Give every button or link that has only an icon a name (from its title, tooltip, id or class) + const NAMES = { more: 'More', navcol: 'Collapse the menu', sx: 'Cancel', x: 'Close', close: 'Close', back: 'Back', fwd: 'Forward', menu: 'Menu', play: 'Play', pause: 'Pause', next: 'Next', prev: 'Previous', shuffle: 'Shuffle', repeat: 'Repeat', send: 'Send', clip: 'Attach a file', lyrics: 'Lyrics', full: 'Full screen', refresh: 'Refresh', ext: 'Open in a new tab' }; + const named = el => (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.textContent.trim() || el.getAttribute('title') || ''); + function label(root) { + root.querySelectorAll('button, a[href], [role="button"]').forEach(el => { + if (named(el)) { if (!el.getAttribute('aria-label') && !el.textContent.trim()) el.setAttribute('aria-label', el.getAttribute('title')); return; } + const key = (el.id || '').replace(/^(btn|b)-?/, '') || [...el.classList].find(c => NAMES[c]) || ''; + const l = NAMES[key] || NAMES[el.id] || (el.querySelector('img[alt]') || {}).alt; + if (l) el.setAttribute('aria-label', l); + }); + root.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]), select, textarea').forEach(el => { + if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || (el.id && document.querySelector(`label[for="${CSS.escape(el.id)}"]`)) || el.closest('label')) return; + const l = el.getAttribute('placeholder') || el.getAttribute('title'); if (l) el.setAttribute('aria-label', l); + }); + } + let pending = false; + new MutationObserver(() => { if (pending) return; pending = true; requestAnimationFrame(() => { pending = false; label(document.body); }); }) + .observe(document.body, { childList: true, subtree: true }); + label(document.body); + + // ---- Sheets (bottom sheets and dialogs): Escape closes, focus moves in and returns, Tab stays inside + let lastFocus = null; + new MutationObserver(() => { + const s = document.getElementById('sheet'); + if (s && !s._a11y) { s._a11y = true; lastFocus = document.activeElement; setTimeout(() => { if (!s.contains(document.activeElement)) { const f = s.querySelector('input:not([type=hidden]), textarea, select, button'); if (f) f.focus(); } }, 50); } + if (!s && lastFocus) { try { lastFocus.focus(); } catch {} lastFocus = null; } + }).observe(document.body, { childList: true }); + addEventListener('keydown', e => { + const s = document.getElementById('sheet'); if (!s) return; + if (e.key === 'Escape' && typeof closeSheet === 'function') { e.preventDefault(); closeSheet(); return; } + if (e.key !== 'Tab') return; + const f = [...s.querySelectorAll('a[href], button:not([disabled]), input:not([type=hidden]):not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])')].filter(x => x.offsetParent !== null); + if (!f.length) return; + const first = f[0], last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && (document.activeElement === last || !s.contains(document.activeElement))) { e.preventDefault(); first.focus(); } + }); +})(); diff --git a/public/style.css b/public/style.css index bc43da8..36c0076 100644 --- a/public/style.css +++ b/public/style.css @@ -495,3 +495,9 @@ body.lyropen { overflow: hidden; } .palrow.on { background: var(--panel2); } .palrow svg { width: 20px; height: 20px; color: var(--muted); } @media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; transition-duration: .01ms !important; } } + +/* Offline notice, skip link, keyboard focus */ +#offline { position: fixed; left: 0; right: 0; top: 0; z-index: 70; padding: calc(var(--sat) + 6px) 16px 6px; text-align: center; font-size: 13px; background: #b26a00; color: #fff; } +.skip { position: fixed; left: 8px; top: -60px; z-index: 80; padding: 10px 14px; border-radius: 10px; background: var(--accent); color: var(--accent-t); } +.skip:focus { top: calc(var(--sat) + 8px); } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } diff --git a/public/sw.js b/public/sw.js index ede84f2..452a2ec 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,11 +1,22 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v6'; +const CACHE = 'hub-v7'; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; -self.addEventListener('install', e => { - e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL))); - self.skipWaiting(); -}); +// Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline. +async function precache() { + const c = await caches.open(CACHE); + await c.addAll(SHELL); + try { + const r = await fetch('/', { credentials: 'same-origin', redirect: 'manual' }); + if (r.ok) { + const html = await r.clone().text(); + await c.put('/', r); + const urls = [...html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css)(?:\?[^"]*)?)"/g)].map(m => m[1]); + await Promise.all(urls.map(u => c.add(u).catch(() => {}))); + } + } catch { /* signed out or offline: it is cached the first time it is used */ } +} +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))))); self.clients.claim(); diff --git a/server.js b/server.js index 1941db2..5362f88 100644 --- a/server.js +++ b/server.js @@ -99,7 +99,7 @@ function serveStatic(req, res) { if (err) { res.writeHead(404); return res.end('Not found'); } // The pages link to /app.js?v= etc. A new release changes the address, so no proxy cache ("Cache Assets" in // Nginx Proxy Manager, a CDN, the browser) can hand out an old copy. - if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); + if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed @@ -113,7 +113,7 @@ function serveStatic(req, res) { } // Changes whenever the app's files change, so an open page can tell that a newer version is available. -const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); +const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); // ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile) const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';