PWA: install button, one icon badge for Signal, mail and tasks due, share target for files, manifest launch handler
build-container / build (push) Successful in 22m24s
build-container / build (push) Successful in 22m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c5a7b5155
commit
924a024b0b
9 files changed
+88
-6
No files matched your search
@@ -14,6 +14,8 @@ Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12
|
||||
docker compose up -d --build # http://localhost:8080
|
||||
# or: node server.js
|
||||
|
||||
**Install extras**: Settings has an Install button where the browser offers one (Chrome, Edge, Android). The installed app's icon badge counts unread Signal chats, unread mail and tasks that are overdue or due today. On Android and desktop Chrome/Edge, NE Hub appears in the Share menu: share files to it, pick a Seafile folder, and they upload.
|
||||
|
||||
Put it behind your reverse proxy with HTTPS (needed for "Add to Home Screen" and the service worker),
|
||||
e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docker-compose.yml.
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
addEventListener('hashchange', route);
|
||||
restorePlayer();
|
||||
route();
|
||||
shareCheck();
|
||||
mailPoll(); setInterval(mailPoll, 2 * 60e3);
|
||||
taskPoll(); setInterval(taskPoll, 10 * 60e3);
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
})();
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
<script src="/file-actions.js"></script>
|
||||
<script src="/signal-view.js"></script>
|
||||
<script src="/mail.js"></script>
|
||||
<script src="/share.js"></script>
|
||||
<script src="/boot.js"></script>
|
||||
<script src="/shell.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -91,5 +91,28 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"dir": "ltr",
|
||||
"prefers_related_applications": false,
|
||||
"launch_handler": {
|
||||
"client_mode": [
|
||||
"navigate-existing",
|
||||
"auto"
|
||||
]
|
||||
},
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"files": [
|
||||
{
|
||||
"name": "files",
|
||||
"accept": [
|
||||
"*/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -91,8 +91,9 @@ function viewSettings() {
|
||||
</div><div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
||||
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
||||
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
|
||||
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2>${installHtml()}</div>`;
|
||||
remindersInit();
|
||||
installInit();
|
||||
// only show what this person may use
|
||||
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
|
||||
hide(!((allowed('mail') && cfg.mail) || cfg.mailAdmin), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
|
||||
@@ -173,3 +174,19 @@ function viewSettings() {
|
||||
$('#hubout').onclick = async () => { try { mcClear(); for (const k of Object.keys(localStorage)) if (k.startsWith('dc:')) localStorage.removeItem(k); } catch {} await authPost('/api/auth/logout'); location.replace('/login'); };
|
||||
const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); };
|
||||
}
|
||||
|
||||
// ---- Install: the browser's own prompt where it offers one (Chrome, Edge, Android); iPhone/iPad have to use Share
|
||||
let installEvt = null;
|
||||
addEventListener('beforeinstallprompt', e => { e.preventDefault(); installEvt = e; const b = document.getElementById('install-btn'); if (b) installInit(); });
|
||||
addEventListener('appinstalled', () => { installEvt = null; const b = document.getElementById('install-box'); if (b) b.innerHTML = '<p class="sub">NE Hub is installed on this device.</p>'; });
|
||||
const isInstalled = () => matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
|
||||
function installHtml() {
|
||||
const body = isInstalled() ? '<p class="sub">NE Hub is installed on this device.</p>'
|
||||
: '<p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p><div class="bar"><button class="btn" id="install-btn" hidden>Install NE Hub</button></div>';
|
||||
return '<div id="install-box">' + body + '</div>';
|
||||
}
|
||||
function installInit() {
|
||||
const b = document.getElementById('install-btn'); if (!b || !installEvt) return;
|
||||
b.hidden = false;
|
||||
b.onclick = async () => { const ev = installEvt; installEvt = null; b.hidden = true; try { await ev.prompt(); } catch {} };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
// Files shared into NE Hub from other apps (the manifest's share target; sw.js keeps them): choose a Seafile folder, then upload.
|
||||
async function shareCheck() {
|
||||
if (!('caches' in window)) return;
|
||||
let c, keys;
|
||||
try { c = await caches.open('hub-share'); keys = await c.keys(); } catch { return; }
|
||||
if (!keys.length) return;
|
||||
const clear = async () => { for (const k of await c.keys()) await c.delete(k); };
|
||||
if (!allowed('files')) { await clear(); toast('You do not have the Files tab, so shared files were discarded'); return; }
|
||||
const files = [];
|
||||
for (const k of keys) { const r = await c.match(k); if (r) files.push(new File([await r.blob()], decodeURIComponent(r.headers.get('x-name') || 'file'), { type: r.headers.get('content-type') || '' })); }
|
||||
const loc = store.get('office_loc');
|
||||
const dst = await chooseFolder('Save ' + (files.length === 1 ? '“' + files[0].name + '”' : files.length + ' shared files') + ' to…', loc ? { repo: loc.repo, repoName: loc.repoName || '', dir: loc.dir } : null, { okLabel: 'Upload here', newFolder: true });
|
||||
await clear();
|
||||
if (!dst) return toast('Shared files discarded');
|
||||
enqueueUploads(dst.repo, dst.dir, files.map(file => ({ file })));
|
||||
}
|
||||
@@ -28,9 +28,10 @@ function sgChime() {
|
||||
g.gain.setValueAtTime(0.0001, t + d); g.gain.exponentialRampToValueAtTime(0.22, t + d + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + d + 0.4);
|
||||
o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); });
|
||||
}
|
||||
function sgBadgeEverywhere() {
|
||||
document.title = (sg.unread ? '(' + (sg.unread > 99 ? '99+' : sg.unread) + ') ' : '') + 'NE Hub';
|
||||
try { if (navigator.setAppBadge) { sg.unread ? navigator.setAppBadge(sg.unread) : navigator.clearAppBadge(); } } catch {}
|
||||
function sgBadgeEverywhere() { // Signal chats, unread mail and tasks due: one number on the tab title and the installed app's icon
|
||||
const n = (sg.unread || 0) + (typeof mailUnread === 'number' ? mailUnread : 0) + (typeof taskDue === 'number' ? taskDue : 0);
|
||||
document.title = (n ? '(' + (n > 99 ? '99+' : n) + ') ' : '') + 'NE Hub';
|
||||
try { if (navigator.setAppBadge) { n ? navigator.setAppBadge(n) : navigator.clearAppBadge(); } } catch {}
|
||||
}
|
||||
function sgAlert(ev) { // a message from someone else arrived and you are not looking at that chat
|
||||
const a = sgAlerts();
|
||||
|
||||
+13
-1
@@ -1,5 +1,6 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v8';
|
||||
const CACHE = 'hub-v9';
|
||||
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'];
|
||||
|
||||
// Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline.
|
||||
@@ -21,8 +22,19 @@ 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();
|
||||
});
|
||||
// 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 {
|
||||
const form = await request.formData(), c = await caches.open(SHARE);
|
||||
for (const k of await c.keys()) await c.delete(k);
|
||||
let i = 0;
|
||||
for (const f of form.getAll('files')) if (f && f.name) await c.put('/share-files/' + (i++), new Response(f, { headers: { 'x-name': encodeURIComponent(f.name), 'content-type': f.type || 'application/octet-stream' } }));
|
||||
} catch { /* nothing usable was shared */ }
|
||||
return Response.redirect(self.location.origin + '/#/files', 303);
|
||||
}
|
||||
self.addEventListener('fetch', e => {
|
||||
const u = new URL(e.request.url);
|
||||
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 || 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)); }
|
||||
|
||||
@@ -99,6 +99,13 @@ function newListSheet(lists, list) {
|
||||
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); }));
|
||||
}
|
||||
|
||||
// Overdue and due-today tasks, for the icon badge
|
||||
let taskDue = 0;
|
||||
async function taskPoll() {
|
||||
if (!allowed('tasks')) return;
|
||||
try { const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = (s.overdue || 0) + (s.today || 0); if (n !== taskDue) { taskDue = n; renderNav(); } } catch {}
|
||||
}
|
||||
|
||||
// Home: a card only when something is overdue or due today
|
||||
async function renderTasksCard(el) {
|
||||
if (!el) return;
|
||||
|
||||
Reference in new issue
Block a user