Wire the new features into the app: routes, settings, Home card, styles, CI, roadmap; bump the app cache

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 09:50:23 -04:00
1 parent bcaedbab5c
commit c51134e0e5
9 files changed
+48 -16

No files matched your search

+1
View File
@@ -367,6 +367,7 @@ function viewHome() {
inboxWire();
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
if (typeof homeResume === 'function') homeResume($('#resume'));
if (typeof homeListening === 'function') homeListening($('#resume'));
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
+2
View File
@@ -82,6 +82,7 @@
<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>
@@ -105,6 +106,7 @@
<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>
</body>
+7 -2
View File
@@ -86,7 +86,7 @@ function viewSettings(args) {
<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>${installHtml()}</div>
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<div id="themeset"></div><h2>Your data</h2><p class="sub">A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.</p><div class="bar"><a class="btn ghost" href="/api/export" download>Download my data</a></div><h2>Install</h2>${installHtml()}</div>
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
@@ -111,7 +111,8 @@ function viewSettings(args) {
<h2>Health</h2><div class="list" id="health"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
<h2>iPad and Screen Time</h2><p class="sub" style="white-space:normal">Allow the hub on an iPad with Screen Time web limits, without adding every site one by one.</p><div class="bar"><button class="btn ghost" id="stbtn">Set up Screen Time</button></div>
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. To restore, choose a backup file below.</p>
<div class="bar"><label class="btn ghost" for="bkfile" style="cursor:pointer">Restore from a backup…</label><input type="file" id="bkfile" accept=".gz,.tgz,application/gzip" hidden></div><p class="sub" id="bkmsg">Replaces the hub's accounts and data with the backup's. The current data is kept in a .pre-restore folder, and the hub restarts.</p>` : ''}
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
</div></div>`;
remindersInit();
@@ -200,6 +201,10 @@ function viewSettings(args) {
el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
if (typeof famSettings === 'function') famSettings($('#famset'));
if (typeof themeSettings === 'function') themeSettings($('#themeset'));
const bkf = $('#bkfile'); if (bkf) bkf.onchange = () => { const f = bkf.files[0]; bkf.value = ''; if (!f) return; confirmSheet('Restore this backup?', `${f.name} replaces the accounts and data on this hub. The current data is kept aside, and the hub restarts.`, 'Restore', async () => {
const m = $('#bkmsg'); m.textContent = 'Uploading and restoring…';
try { const r = await fetch('/api/backup/restore', { method: 'POST', body: f }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not restore'); m.textContent = 'Restored. The hub is restarting; this page reloads in a moment.'; setTimeout(() => location.reload(), 6000); } catch (e) { m.textContent = e.message; toast(e.message); } }); };
$('#invpk').onclick = async () => {
try {
const { code, minutes } = await authPost('/api/auth/invite');
+7 -1
View File
@@ -773,7 +773,7 @@ body.nav-mini .ngrp span { display: none; }
.vdeckc { position: relative; flex: none; }
.vx { position: absolute; top: 6px; left: 6px; width: 28px; height: 28px; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; cursor: pointer; opacity: .85; }
.vx svg { width: 14px; height: 14px; } .vx:hover, .vx:focus-visible { opacity: 1; background: rgba(0, 0, 0, .85); }
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { min-height: 40px; max-width: 48vw; }
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { width: auto; flex: 0 1 auto; min-height: 40px; max-width: 48vw; }
.vunw { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; } .vunw input { width: 18px; height: 18px; }
.vmark { position: absolute; right: 16px; bottom: 96px; z-index: 6; background: rgba(0, 0, 0, .7); color: #fff; border: 1px solid rgba(255, 255, 255, .5); }
.vdeckc .vcard.wide { width: 250px; }
@@ -1095,3 +1095,9 @@ body.simple #shell { flex-direction: column; }
body.simple #view { padding-bottom: 110px; }
/* while a sheet, the lyrics or the visualizer covers the screen, the floating Home and Ask buttons step aside so they never sit on its controls */
body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simplehome, body:has(#lyr) #kidreq, body.vizopen #simplehome, body.vizopen #kidreq { display: none !important; }
.swatches { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0; } .swatches button { width: 40px; height: 40px; min-height: 0; padding: 0; border-radius: 50%; border: 3px solid var(--panel); box-shadow: 0 0 0 1px var(--line); } .swatches button.on { box-shadow: 0 0 0 3px var(--text); }
.dvverse { display: block; max-width: none; } .dvverse p { font-family: Georgia, 'Times New Roman', serif; font-size: 19px; line-height: 1.5; margin: 0 0 8px; } .dvverse small { color: var(--muted); } .dvread { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
.lsrow { display: flex; gap: 16px; } .lsrow span { flex: 1; min-width: 0; } .lsrow b { display: block; font-size: 22px; letter-spacing: -.01em; } .lsrow small { color: var(--muted); }
.lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; }
.lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v95';
const CACHE = 'hub-v102';
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'];
+14
View File
@@ -28,3 +28,17 @@ async function homeResume(el) {
el.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (today.books || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
}
const today = { books: [] };
// Home: how much you listened this week (podcasts and audiobooks), the day streak, and a yearly goal for audiobooks finished. The goal is kept in your synced settings.
const lsTime = sec => { const m = Math.round(sec / 60); return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : ''); };
async function homeListening(after) {
if (!after || !(allowed('podcast') || allowed('audio'))) return;
let s; try { s = await jfetch('/api/listening/stats'); } catch { return; }
const goal = +store.get('read_goal', 0) || 0; if (!after.isConnected || (!s.week && !goal && !s.finishedYear)) return;
const max = Math.max(60, ...s.last14.map(d => d.s)), card = document.createElement('div'); card.id = 'listening';
card.innerHTML = `<h2>Listening</h2><div class="card" style="max-width:none"><div class="lsrow"><span><b>${lsTime(s.week)}</b><small>this week</small></span><span><b>${s.streak}</b><small>day${s.streak === 1 ? '' : 's'} in a row</small></span><span><b>${s.finishedYear}${goal ? ' / ' + goal : ''}</b><small>audiobooks finished this year</small></span></div>
<div class="lsbars" role="img" aria-label="Minutes listened each of the last 14 days">${s.last14.map(d => `<i style="height:${Math.max(3, Math.round(d.s / max * 100))}%" title="${esc(d.day)}: ${lsTime(d.s)}"></i>`).join('')}</div>
${goal ? `<div class="lsgoal"><i style="width:${Math.min(100, Math.round(s.finishedYear / goal * 100))}%"></i></div>` : ''}<div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="lsgoal">${goal ? 'Change yearly goal' : 'Set a yearly goal'}</button></div></div>`;
after.insertAdjacentElement('afterend', card);
card.querySelector('#lsgoal').onclick = () => { const el = openSheet('Audiobooks to finish this year', '', `<form id="lsf"><label for="lsn">Goal</label><input type="number" id="lsn" min="0" max="500" value="${goal || ''}" placeholder="12" inputmode="numeric"><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`); el.querySelector('#lsf').onsubmit = e => { e.preventDefault(); const n = Math.max(0, Math.min(500, Math.round(+el.querySelector('#lsn').value) || 0)); if (n) store.set('read_goal', n); else store.del('read_goal'); closeSheet(); card.remove(); homeListening(after); }; };
}